From 761202677302a6e95a101cef683b7b0ea0d56e96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8A=80=E6=9C=AF=E8=80=81=E8=83=A1?= <1094551889@qq.com> Date: Wed, 15 Apr 2026 11:45:46 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 28 + Dockerfile | 18 + app/command/EpayMapiTest.php | 667 ++++++ app/command/MpayTest.php | 510 ++++ app/command/SystemConfigSync.php | 70 + app/common/base/BaseController.php | 101 +- app/common/base/BaseModel.php | 31 +- app/common/base/BasePayment.php | 12 +- app/common/base/BaseRepository.php | 165 +- app/common/base/BaseService.php | 159 +- app/common/constant/AuthConstant.php | 39 + app/common/constant/CommonConstant.php | 31 + app/common/constant/FileConstant.php | 135 ++ app/common/constant/LedgerConstant.php | 54 + app/common/constant/MerchantConstant.php | 35 + app/common/constant/NotifyConstant.php | 70 + app/common/constant/RouteConstant.php | 55 + app/common/constant/TradeConstant.php | 157 ++ app/common/constants/YesNo.php | 15 - app/common/enums/MenuType.php | 28 - .../PayPluginInterface.php | 20 +- .../PaymentInterface.php | 30 +- app/common/middleware/Cors.php | 12 +- app/common/middleware/StaticFile.php | 42 - app/common/payment/AlipayPayment.php | 552 ++++- app/common/payment/LakalaPayment.php | 87 - app/common/util/FormatHelper.php | 188 ++ app/common/util/JwtTokenManager.php | 254 ++ app/common/utils/EpayUtil.php | 65 - app/common/utils/JwtUtil.php | 61 - app/events/SystemConfig.php | 28 - .../BalanceInsufficientException.php | 33 + app/exception/BusinessStateException.php | 25 + app/exception/ConflictException.php | 25 + .../NotifyRetryExceededException.php | 31 + app/exception/PaymentException.php | 25 + app/exception/ResourceNotFoundException.php | 25 + app/exception/ValidationException.php | 30 + app/exceptions/BadRequestException.php | 25 - app/exceptions/ForbiddenException.php | 25 - app/exceptions/InternalServerException.php | 26 - app/exceptions/NotFoundException.php | 25 - app/exceptions/PaymentException.php | 25 - app/exceptions/UnauthorizedException.php | 26 - app/exceptions/ValidationException.php | 25 - app/http/admin/controller/AdminController.php | 34 - app/http/admin/controller/AuthController.php | 55 - .../admin/controller/ChannelController.php | 651 ------ .../admin/controller/FinanceController.php | 522 ----- app/http/admin/controller/MenuController.php | 25 - .../controller/MerchantAppController.php | 303 --- .../admin/controller/MerchantController.php | 884 ------- app/http/admin/controller/OrderController.php | 316 --- .../admin/controller/PayMethodController.php | 96 - .../admin/controller/PayPluginController.php | 85 - .../admin/controller/PluginController.php | 71 - .../admin/controller/SystemController.php | 312 --- .../account/MerchantAccountController.php | 63 + .../MerchantAccountLedgerController.php | 54 + .../controller/file/FileRecordController.php | 118 + .../MerchantApiCredentialController.php | 103 + .../merchant/MerchantController.php | 141 ++ .../merchant/MerchantGroupController.php | 118 + .../merchant/MerchantPolicyController.php | 66 + .../ops/ChannelDailyStatController.php | 54 + .../ops/ChannelNotifyLogController.php | 54 + .../ops/PayCallbackLogController.php | 54 + .../payment/PaymentChannelController.php | 138 ++ .../payment/PaymentPluginConfController.php | 98 + .../payment/PaymentPluginController.php | 123 + .../PaymentPollGroupBindController.php | 77 + .../PaymentPollGroupChannelController.php | 79 + .../payment/PaymentPollGroupController.php | 119 + .../payment/PaymentTypeController.php | 105 + .../controller/payment/RouteController.php | 43 + .../controller/system/AdminUserController.php | 122 + .../controller/system/AuthController.php | 65 + .../system/SystemConfigPageController.php | 42 + .../controller/system/SystemController.php | 30 + .../controller/trade/PayOrderController.php | 38 + .../trade/RefundOrderController.php | 56 + .../trade/SettlementOrderController.php | 53 + .../admin/middleware/AdminAuthMiddleware.php | 62 + app/http/admin/middleware/AuthMiddleware.php | 73 - .../admin/validation/AdminUserValidator.php | 83 + app/http/admin/validation/AuthValidator.php | 27 + .../validation/ChannelDailyStatValidator.php | 36 + .../validation/ChannelNotifyLogValidator.php | 40 + .../admin/validation/FileRecordValidator.php | 45 + .../MerchantAccountLedgerValidator.php | 38 + .../validation/MerchantAccountValidator.php | 32 + .../MerchantApiCredentialValidator.php | 71 + .../validation/MerchantGroupValidator.php | 70 + .../validation/MerchantPolicyValidator.php | 89 + .../admin/validation/MerchantValidator.php | 154 ++ .../validation/PayCallbackLogValidator.php | 40 + .../admin/validation/PayOrderValidator.php | 39 + .../validation/PaymentChannelValidator.php | 103 + .../validation/PaymentPluginConfValidator.php | 68 + .../validation/PaymentPluginValidator.php | 45 + .../PaymentPollGroupBindValidator.php | 70 + .../PaymentPollGroupChannelValidator.php | 83 + .../validation/PaymentPollGroupValidator.php | 75 + .../admin/validation/PaymentTypeValidator.php | 75 + .../validation/RefundActionValidator.php | 36 + .../admin/validation/RefundOrderValidator.php | 37 + .../validation/RouteResolveValidator.php | 35 + .../validation/SettlementOrderValidator.php | 38 + .../validation/SystemConfigPageValidator.php | 23 + app/http/api/controller/EpayController.php | 98 - app/http/api/controller/PayController.php | 74 - .../api/controller/adapter/EpayController.php | 100 + .../controller/notify/NotifyController.php | 51 + .../api/controller/route/RouteController.php | 43 + .../settlement/SettlementController.php | 78 + .../api/controller/trace/TraceController.php | 39 + .../api/controller/trade/PayController.php | 138 ++ .../api/controller/trade/RefundController.php | 93 + .../api/middleware/EpayAuthMiddleware.php | 69 - .../api/middleware/OpenApiAuthMiddleware.php | 69 - app/http/api/validation/EpayValidator.php | 113 + .../api/validation/NotifyChannelValidator.php | 49 + .../validation/NotifyMerchantValidator.php | 47 + .../api/validation/PayCallbackValidator.php | 53 + app/http/api/validation/PayCloseValidator.php | 31 + .../api/validation/PayPrepareValidator.php | 37 + .../api/validation/PayTimeoutValidator.php | 31 + .../api/validation/RefundActionValidator.php | 37 + .../api/validation/RefundCreateValidator.php | 33 + .../api/validation/RouteResolveValidator.php | 31 + .../validation/SettlementActionValidator.php | 30 + .../validation/SettlementCreateValidator.php | 63 + .../api/validation/TraceQueryValidator.php | 23 + .../controller/account/AccountController.php | 47 + .../merchant/MerchantPortalController.php | 191 ++ .../mer/controller/system/AuthController.php | 61 + .../controller/system/SystemController.php | 30 + .../controller/trade/PayOrderController.php | 44 + .../trade/RefundOrderController.php | 72 + .../mer/middleware/MerchantAuthMiddleware.php | 63 + app/http/mer/validation/AuthValidator.php | 25 + app/http/mer/validation/BalanceValidator.php | 25 + .../validation/MerchantPortalValidator.php | 60 + app/http/mer/validation/PayOrderValidator.php | 39 + .../mer/validation/RefundActionValidator.php | 36 + .../mer/validation/RefundOrderValidator.php | 37 + app/jobs/NotifyMerchantJob.php | 51 - app/listener/SystemConfigChangedListener.php | 18 + app/model/admin/AdminUser.php | 41 + app/model/admin/ChannelDailyStat.php | 48 + app/model/admin/ChannelNotifyLog.php | 47 + .../admin/PayCallbackLog.php} | 22 +- app/model/file/FileRecord.php | 43 + app/model/merchant/Merchant.php | 51 + app/model/merchant/MerchantAccount.php | 28 + app/model/merchant/MerchantAccountLedger.php | 50 + app/model/merchant/MerchantApiCredential.php | 35 + app/model/merchant/MerchantGroup.php | 27 + app/model/merchant/MerchantPolicy.php | 41 + app/model/payment/BizOrder.php | 58 + app/model/payment/NotifyTask.php | 47 + app/model/payment/PayOrder.php | 83 + app/model/payment/PaymentChannel.php | 51 + .../payment}/PaymentPlugin.php | 21 +- app/model/payment/PaymentPluginConf.php | 31 + app/model/payment/PaymentPollGroup.php | 31 + app/model/payment/PaymentPollGroupBind.php | 33 + app/model/payment/PaymentPollGroupChannel.php | 37 + app/model/payment/PaymentType.php | 32 + app/model/payment/RefundOrder.php | 57 + app/model/payment/SettlementItem.php | 45 + app/model/payment/SettlementOrder.php | 60 + app/model/system/SystemConfig.php | 32 + app/models/Admin.php | 36 - app/models/CallbackInbox.php | 34 - app/models/Merchant.php | 33 - app/models/MerchantApp.php | 73 - app/models/MerchantUser.php | 51 - app/models/PaymentChannel.php | 105 - app/models/PaymentMethod.php | 52 - app/models/PaymentNotifyTask.php | 42 - app/models/PaymentOrder.php | 63 - app/models/SystemConfig.php | 38 - app/process/Monitor.php | 75 +- app/repositories/AdminRepository.php | 31 - app/repositories/CallbackInboxRepository.php | 43 - app/repositories/MerchantAppRepository.php | 99 - app/repositories/MerchantRepository.php | 56 - .../PaymentCallbackLogRepository.php | 22 - app/repositories/PaymentChannelRepository.php | 77 - app/repositories/PaymentMethodRepository.php | 66 - .../PaymentNotifyTaskRepository.php | 34 - app/repositories/PaymentOrderRepository.php | 197 -- app/repositories/PaymentPluginRepository.php | 96 - app/repositories/SystemConfigRepository.php | 157 -- .../balance/MerchantAccountRepository.php | 52 + .../MerchantAccountLedgerRepository.php | 78 + app/repository/file/FileRecordRepository.php | 24 + .../merchant/base/MerchantGroupRepository.php | 47 + .../base/MerchantPolicyRepository.php | 32 + .../merchant/base/MerchantRepository.php | 43 + .../MerchantApiCredentialRepository.php | 41 + .../ops/log/ChannelNotifyLogRepository.php | 44 + .../ops/log/PayCallbackLogRepository.php | 33 + .../ops/stat/ChannelDailyStatRepository.php | 33 + .../config/PaymentChannelRepository.php | 81 + .../config/PaymentPluginConfRepository.php | 33 + .../config/PaymentPluginRepository.php | 43 + .../config/PaymentPollGroupBindRepository.php | 57 + .../PaymentPollGroupChannelRepository.php | 49 + .../config/PaymentPollGroupRepository.php | 35 + .../payment/config/PaymentTypeRepository.php | 43 + .../payment/notify/NotifyTaskRepository.php | 43 + .../settlement/SettlementItemRepository.php | 33 + .../settlement/SettlementOrderRepository.php | 114 + .../payment/trade/BizOrderRepository.php | 107 + .../payment/trade/PayOrderRepository.php | 143 ++ .../payment/trade/RefundOrderRepository.php | 127 + .../system/config/SystemConfigRepository.php | 20 + .../system/user/AdminUserRepository.php | 32 + app/route/admin.php | 175 ++ app/route/api.php | 60 + app/route/mer.php | 43 + app/routes/admin.php | 230 -- app/routes/api.php | 23 - app/routes/mer.php | 8 - .../funds/MerchantAccountCommandService.php | 349 +++ .../funds/MerchantAccountQueryService.php | 160 ++ .../account/funds/MerchantAccountService.php | 101 + .../ledger/MerchantAccountLedgerService.php | 138 ++ .../bootstrap/SystemBootstrapService.php | 157 ++ app/service/file/FileRecordCommandService.php | 281 +++ app/service/file/FileRecordQueryService.php | 176 ++ app/service/file/FileRecordService.php | 64 + app/service/file/StorageConfigService.php | 202 ++ .../file/storage/AbstractStorageDriver.php | 113 + app/service/file/storage/CosStorageDriver.php | 188 ++ .../file/storage/LocalStorageDriver.php | 121 + app/service/file/storage/OssStorageDriver.php | 196 ++ .../file/storage/RemoteUrlStorageDriver.php | 53 + .../file/storage/StorageDriverInterface.php | 25 + app/service/file/storage/StorageManager.php | 158 ++ .../merchant/MerchantCommandService.php | 269 +++ .../merchant/MerchantOverviewQueryService.php | 130 ++ app/service/merchant/MerchantQueryService.php | 235 ++ app/service/merchant/MerchantService.php | 129 ++ .../merchant/auth/MerchantAuthService.php | 181 ++ .../merchant/group/MerchantGroupService.php | 120 + .../merchant/policy/MerchantPolicyService.php | 161 ++ .../portal/MerchantPortalBalanceService.php | 50 + .../MerchantPortalChannelQueryService.php | 113 + .../portal/MerchantPortalChannelService.php | 29 + ...MerchantPortalCredentialCommandService.php | 53 + .../MerchantPortalCredentialQueryService.php | 53 + .../MerchantPortalCredentialService.php | 27 + .../portal/MerchantPortalFinanceService.php | 39 + .../MerchantPortalProfileCommandService.php | 69 + .../MerchantPortalProfileQueryService.php | 24 + .../portal/MerchantPortalProfileService.php | 32 + .../MerchantPortalRoutePreviewService.php | 178 ++ .../merchant/portal/MerchantPortalService.php | 76 + .../MerchantPortalSettlementService.php | 47 + .../portal/MerchantPortalSupportService.php | 201 ++ .../MerchantApiCredentialQueryService.php | 128 + .../security/MerchantApiCredentialService.php | 265 +++ .../ops/log/ChannelNotifyLogService.php | 154 ++ app/service/ops/log/PayCallbackLogService.php | 141 ++ .../ops/stat/ChannelDailyStatService.php | 132 ++ .../payment/compat/EpayCompatService.php | 571 +++++ .../config/PaymentChannelCommandService.php | 119 + .../config/PaymentChannelQueryService.php | 228 ++ .../payment/config/PaymentChannelService.php | 58 + .../config/PaymentPluginConfService.php | 228 ++ .../payment/config/PaymentPluginService.php | 207 ++ .../config/PaymentPluginSyncService.php | 122 + .../config/PaymentPollGroupBindService.php | 162 ++ .../config/PaymentPollGroupChannelService.php | 184 ++ .../config/PaymentPollGroupCommandService.php | 55 + .../config/PaymentPollGroupQueryService.php | 79 + .../config/PaymentPollGroupService.php | 48 + .../payment/config/PaymentTypeService.php | 150 ++ .../payment/order/PayOrderAttemptService.php | 256 ++ .../payment/order/PayOrderCallbackService.php | 139 ++ .../order/PayOrderChannelDispatchService.php | 134 ++ .../payment/order/PayOrderFeeService.php | 140 ++ .../order/PayOrderLifecycleService.php | 322 +++ .../payment/order/PayOrderQueryService.php | 253 ++ .../payment/order/PayOrderReportService.php | 92 + app/service/payment/order/PayOrderService.php | 131 ++ .../payment/order/RefundCreationService.php | 116 + .../payment/order/RefundLifecycleService.php | 251 ++ .../payment/order/RefundQueryService.php | 285 +++ .../payment/order/RefundReportService.php | 100 + app/service/payment/order/RefundService.php | 111 + app/service/payment/runtime/NotifyService.php | 191 ++ .../runtime/PaymentPluginFactoryService.php | 212 ++ .../payment/runtime/PaymentPluginManager.php | 42 + .../runtime/PaymentRouteResolverService.php | 276 +++ .../payment/runtime/PaymentRouteService.php | 26 + .../settlement/SettlementLifecycleService.php | 270 +++ .../SettlementOrderQueryService.php | 226 ++ .../payment/settlement/SettlementService.php | 55 + .../payment/trace/TradeTraceReportService.php | 248 ++ .../payment/trace/TradeTraceService.php | 269 +++ .../system/access/AdminAuthService.php | 113 + .../config/SystemConfigDefinitionService.php | 242 ++ .../system/config/SystemConfigPageService.php | 160 ++ .../config/SystemConfigRuntimeService.php | 124 + app/service/system/user/AdminUserService.php | 196 ++ app/services/AdminService.php | 37 - app/services/AuthService.php | 96 - app/services/CaptchaService.php | 101 - app/services/ChannelRoutePolicyService.php | 103 - app/services/ChannelRouterService.php | 613 ----- app/services/MenuService.php | 89 - app/services/NotifyService.php | 121 - app/services/PayNotifyService.php | 199 -- app/services/PayOrderService.php | 189 -- app/services/PayService.php | 213 -- app/services/PaymentStateService.php | 113 - app/services/PluginService.php | 169 -- app/services/SystemConfigService.php | 82 - app/services/SystemSettingService.php | 211 -- app/services/api/EpayProtocolService.php | 106 - app/services/api/EpayService.php | 267 --- app/validation/EpayValidator.php | 122 - app/validation/SystemConfigValidator.php | 23 - composer.json | 28 +- composer.lock | 794 ++++--- config/auth.php | 28 + config/autoload.php | 2 +- config/base-config/basic.json | 83 - config/base-config/email.json | 111 - config/base-config/permission.json | 98 - config/base-config/tabs.json | 26 - config/bootstrap.php | 2 +- config/cache.php | 4 +- config/container.php | 10 +- config/database.php | 10 +- config/dict.php | 201 ++ config/event.php | 5 +- config/jwt.php | 19 - config/menu.php | 2050 ++++++++--------- config/middleware.php | 3 +- config/plugin/webman/redis-queue/app.php | 4 - config/plugin/webman/redis-queue/command.php | 7 - config/plugin/webman/redis-queue/log.php | 32 - config/plugin/webman/redis-queue/process.php | 11 - config/plugin/webman/redis-queue/redis.php | 21 - config/plugin/webman/validation/app.php | 2 +- config/redis.php | 17 +- config/route.php | 24 +- config/system-file/dict.json | 45 - config/system-file/menu.json | 1059 --------- config/system-file/menu.md | 137 -- config/system_config.php | 608 +++++ database/20260320_align_current_schema.sql | 169 -- database/dev_seed.sql | 194 -- database/mvp_payment_tables.sql | 251 -- database/patch_callback_inbox.sql | 20 - doc/epay.md | 216 -- doc/event.md | 112 - doc/exception.md | 114 - doc/project_overview.md | 414 ---- doc/project_progress.md | 320 --- doc/skill.md | 312 --- doc/validation.md | 395 ---- docker-compose.yml | 11 + public/admin/index.html | 21 + public/cashier/index.html | 13 + public/mer/index.html | 21 + .../04/10/20260410105634_55bfa9769bdcf5b9.png | Bin 0 -> 82117 bytes .../04/10/20260410110612_3d43ca03132fa6ea.svg | 1 + .../04/10/20260410132654_125e9fa2417c0005.svg | 1 + .../04/10/20260410132722_b07354346c3ab8b0.svg | 1 + .../04/10/20260410132828_1f3f2a6ef52e1bb1.svg | 1 + .../04/10/20260410144242_3209013f5b3255b0.jpg | Bin 0 -> 48112 bytes support/Setup.php | 1558 +++++++++++++ support/functions.php | 52 + support/helpers.php | 59 - test.php | 14 - 381 files changed, 28287 insertions(+), 14717 deletions(-) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 app/command/EpayMapiTest.php create mode 100644 app/command/MpayTest.php create mode 100644 app/command/SystemConfigSync.php create mode 100644 app/common/constant/AuthConstant.php create mode 100644 app/common/constant/CommonConstant.php create mode 100644 app/common/constant/FileConstant.php create mode 100644 app/common/constant/LedgerConstant.php create mode 100644 app/common/constant/MerchantConstant.php create mode 100644 app/common/constant/NotifyConstant.php create mode 100644 app/common/constant/RouteConstant.php create mode 100644 app/common/constant/TradeConstant.php delete mode 100644 app/common/constants/YesNo.php delete mode 100644 app/common/enums/MenuType.php rename app/common/{contracts => interface}/PayPluginInterface.php (63%) rename app/common/{contracts => interface}/PaymentInterface.php (75%) delete mode 100644 app/common/middleware/StaticFile.php delete mode 100644 app/common/payment/LakalaPayment.php create mode 100644 app/common/util/FormatHelper.php create mode 100644 app/common/util/JwtTokenManager.php delete mode 100644 app/common/utils/EpayUtil.php delete mode 100644 app/common/utils/JwtUtil.php delete mode 100644 app/events/SystemConfig.php create mode 100644 app/exception/BalanceInsufficientException.php create mode 100644 app/exception/BusinessStateException.php create mode 100644 app/exception/ConflictException.php create mode 100644 app/exception/NotifyRetryExceededException.php create mode 100644 app/exception/PaymentException.php create mode 100644 app/exception/ResourceNotFoundException.php create mode 100644 app/exception/ValidationException.php delete mode 100644 app/exceptions/BadRequestException.php delete mode 100644 app/exceptions/ForbiddenException.php delete mode 100644 app/exceptions/InternalServerException.php delete mode 100644 app/exceptions/NotFoundException.php delete mode 100644 app/exceptions/PaymentException.php delete mode 100644 app/exceptions/UnauthorizedException.php delete mode 100644 app/exceptions/ValidationException.php delete mode 100644 app/http/admin/controller/AdminController.php delete mode 100644 app/http/admin/controller/AuthController.php delete mode 100644 app/http/admin/controller/ChannelController.php delete mode 100644 app/http/admin/controller/FinanceController.php delete mode 100644 app/http/admin/controller/MenuController.php delete mode 100644 app/http/admin/controller/MerchantAppController.php delete mode 100644 app/http/admin/controller/MerchantController.php delete mode 100644 app/http/admin/controller/OrderController.php delete mode 100644 app/http/admin/controller/PayMethodController.php delete mode 100644 app/http/admin/controller/PayPluginController.php delete mode 100644 app/http/admin/controller/PluginController.php delete mode 100644 app/http/admin/controller/SystemController.php create mode 100644 app/http/admin/controller/account/MerchantAccountController.php create mode 100644 app/http/admin/controller/account/MerchantAccountLedgerController.php create mode 100644 app/http/admin/controller/file/FileRecordController.php create mode 100644 app/http/admin/controller/merchant/MerchantApiCredentialController.php create mode 100644 app/http/admin/controller/merchant/MerchantController.php create mode 100644 app/http/admin/controller/merchant/MerchantGroupController.php create mode 100644 app/http/admin/controller/merchant/MerchantPolicyController.php create mode 100644 app/http/admin/controller/ops/ChannelDailyStatController.php create mode 100644 app/http/admin/controller/ops/ChannelNotifyLogController.php create mode 100644 app/http/admin/controller/ops/PayCallbackLogController.php create mode 100644 app/http/admin/controller/payment/PaymentChannelController.php create mode 100644 app/http/admin/controller/payment/PaymentPluginConfController.php create mode 100644 app/http/admin/controller/payment/PaymentPluginController.php create mode 100644 app/http/admin/controller/payment/PaymentPollGroupBindController.php create mode 100644 app/http/admin/controller/payment/PaymentPollGroupChannelController.php create mode 100644 app/http/admin/controller/payment/PaymentPollGroupController.php create mode 100644 app/http/admin/controller/payment/PaymentTypeController.php create mode 100644 app/http/admin/controller/payment/RouteController.php create mode 100644 app/http/admin/controller/system/AdminUserController.php create mode 100644 app/http/admin/controller/system/AuthController.php create mode 100644 app/http/admin/controller/system/SystemConfigPageController.php create mode 100644 app/http/admin/controller/system/SystemController.php create mode 100644 app/http/admin/controller/trade/PayOrderController.php create mode 100644 app/http/admin/controller/trade/RefundOrderController.php create mode 100644 app/http/admin/controller/trade/SettlementOrderController.php create mode 100644 app/http/admin/middleware/AdminAuthMiddleware.php delete mode 100644 app/http/admin/middleware/AuthMiddleware.php create mode 100644 app/http/admin/validation/AdminUserValidator.php create mode 100644 app/http/admin/validation/AuthValidator.php create mode 100644 app/http/admin/validation/ChannelDailyStatValidator.php create mode 100644 app/http/admin/validation/ChannelNotifyLogValidator.php create mode 100644 app/http/admin/validation/FileRecordValidator.php create mode 100644 app/http/admin/validation/MerchantAccountLedgerValidator.php create mode 100644 app/http/admin/validation/MerchantAccountValidator.php create mode 100644 app/http/admin/validation/MerchantApiCredentialValidator.php create mode 100644 app/http/admin/validation/MerchantGroupValidator.php create mode 100644 app/http/admin/validation/MerchantPolicyValidator.php create mode 100644 app/http/admin/validation/MerchantValidator.php create mode 100644 app/http/admin/validation/PayCallbackLogValidator.php create mode 100644 app/http/admin/validation/PayOrderValidator.php create mode 100644 app/http/admin/validation/PaymentChannelValidator.php create mode 100644 app/http/admin/validation/PaymentPluginConfValidator.php create mode 100644 app/http/admin/validation/PaymentPluginValidator.php create mode 100644 app/http/admin/validation/PaymentPollGroupBindValidator.php create mode 100644 app/http/admin/validation/PaymentPollGroupChannelValidator.php create mode 100644 app/http/admin/validation/PaymentPollGroupValidator.php create mode 100644 app/http/admin/validation/PaymentTypeValidator.php create mode 100644 app/http/admin/validation/RefundActionValidator.php create mode 100644 app/http/admin/validation/RefundOrderValidator.php create mode 100644 app/http/admin/validation/RouteResolveValidator.php create mode 100644 app/http/admin/validation/SettlementOrderValidator.php create mode 100644 app/http/admin/validation/SystemConfigPageValidator.php delete mode 100644 app/http/api/controller/EpayController.php delete mode 100644 app/http/api/controller/PayController.php create mode 100644 app/http/api/controller/adapter/EpayController.php create mode 100644 app/http/api/controller/notify/NotifyController.php create mode 100644 app/http/api/controller/route/RouteController.php create mode 100644 app/http/api/controller/settlement/SettlementController.php create mode 100644 app/http/api/controller/trace/TraceController.php create mode 100644 app/http/api/controller/trade/PayController.php create mode 100644 app/http/api/controller/trade/RefundController.php delete mode 100644 app/http/api/middleware/EpayAuthMiddleware.php delete mode 100644 app/http/api/middleware/OpenApiAuthMiddleware.php create mode 100644 app/http/api/validation/EpayValidator.php create mode 100644 app/http/api/validation/NotifyChannelValidator.php create mode 100644 app/http/api/validation/NotifyMerchantValidator.php create mode 100644 app/http/api/validation/PayCallbackValidator.php create mode 100644 app/http/api/validation/PayCloseValidator.php create mode 100644 app/http/api/validation/PayPrepareValidator.php create mode 100644 app/http/api/validation/PayTimeoutValidator.php create mode 100644 app/http/api/validation/RefundActionValidator.php create mode 100644 app/http/api/validation/RefundCreateValidator.php create mode 100644 app/http/api/validation/RouteResolveValidator.php create mode 100644 app/http/api/validation/SettlementActionValidator.php create mode 100644 app/http/api/validation/SettlementCreateValidator.php create mode 100644 app/http/api/validation/TraceQueryValidator.php create mode 100644 app/http/mer/controller/account/AccountController.php create mode 100644 app/http/mer/controller/merchant/MerchantPortalController.php create mode 100644 app/http/mer/controller/system/AuthController.php create mode 100644 app/http/mer/controller/system/SystemController.php create mode 100644 app/http/mer/controller/trade/PayOrderController.php create mode 100644 app/http/mer/controller/trade/RefundOrderController.php create mode 100644 app/http/mer/middleware/MerchantAuthMiddleware.php create mode 100644 app/http/mer/validation/AuthValidator.php create mode 100644 app/http/mer/validation/BalanceValidator.php create mode 100644 app/http/mer/validation/MerchantPortalValidator.php create mode 100644 app/http/mer/validation/PayOrderValidator.php create mode 100644 app/http/mer/validation/RefundActionValidator.php create mode 100644 app/http/mer/validation/RefundOrderValidator.php delete mode 100644 app/jobs/NotifyMerchantJob.php create mode 100644 app/listener/SystemConfigChangedListener.php create mode 100644 app/model/admin/AdminUser.php create mode 100644 app/model/admin/ChannelDailyStat.php create mode 100644 app/model/admin/ChannelNotifyLog.php rename app/{models/PaymentCallbackLog.php => model/admin/PayCallbackLog.php} (53%) create mode 100644 app/model/file/FileRecord.php create mode 100644 app/model/merchant/Merchant.php create mode 100644 app/model/merchant/MerchantAccount.php create mode 100644 app/model/merchant/MerchantAccountLedger.php create mode 100644 app/model/merchant/MerchantApiCredential.php create mode 100644 app/model/merchant/MerchantGroup.php create mode 100644 app/model/merchant/MerchantPolicy.php create mode 100644 app/model/payment/BizOrder.php create mode 100644 app/model/payment/NotifyTask.php create mode 100644 app/model/payment/PayOrder.php create mode 100644 app/model/payment/PaymentChannel.php rename app/{models => model/payment}/PaymentPlugin.php (66%) create mode 100644 app/model/payment/PaymentPluginConf.php create mode 100644 app/model/payment/PaymentPollGroup.php create mode 100644 app/model/payment/PaymentPollGroupBind.php create mode 100644 app/model/payment/PaymentPollGroupChannel.php create mode 100644 app/model/payment/PaymentType.php create mode 100644 app/model/payment/RefundOrder.php create mode 100644 app/model/payment/SettlementItem.php create mode 100644 app/model/payment/SettlementOrder.php create mode 100644 app/model/system/SystemConfig.php delete mode 100644 app/models/Admin.php delete mode 100644 app/models/CallbackInbox.php delete mode 100644 app/models/Merchant.php delete mode 100644 app/models/MerchantApp.php delete mode 100644 app/models/MerchantUser.php delete mode 100644 app/models/PaymentChannel.php delete mode 100644 app/models/PaymentMethod.php delete mode 100644 app/models/PaymentNotifyTask.php delete mode 100644 app/models/PaymentOrder.php delete mode 100644 app/models/SystemConfig.php delete mode 100644 app/repositories/AdminRepository.php delete mode 100644 app/repositories/CallbackInboxRepository.php delete mode 100644 app/repositories/MerchantAppRepository.php delete mode 100644 app/repositories/MerchantRepository.php delete mode 100644 app/repositories/PaymentCallbackLogRepository.php delete mode 100644 app/repositories/PaymentChannelRepository.php delete mode 100644 app/repositories/PaymentMethodRepository.php delete mode 100644 app/repositories/PaymentNotifyTaskRepository.php delete mode 100644 app/repositories/PaymentOrderRepository.php delete mode 100644 app/repositories/PaymentPluginRepository.php delete mode 100644 app/repositories/SystemConfigRepository.php create mode 100644 app/repository/account/balance/MerchantAccountRepository.php create mode 100644 app/repository/account/ledger/MerchantAccountLedgerRepository.php create mode 100644 app/repository/file/FileRecordRepository.php create mode 100644 app/repository/merchant/base/MerchantGroupRepository.php create mode 100644 app/repository/merchant/base/MerchantPolicyRepository.php create mode 100644 app/repository/merchant/base/MerchantRepository.php create mode 100644 app/repository/merchant/credential/MerchantApiCredentialRepository.php create mode 100644 app/repository/ops/log/ChannelNotifyLogRepository.php create mode 100644 app/repository/ops/log/PayCallbackLogRepository.php create mode 100644 app/repository/ops/stat/ChannelDailyStatRepository.php create mode 100644 app/repository/payment/config/PaymentChannelRepository.php create mode 100644 app/repository/payment/config/PaymentPluginConfRepository.php create mode 100644 app/repository/payment/config/PaymentPluginRepository.php create mode 100644 app/repository/payment/config/PaymentPollGroupBindRepository.php create mode 100644 app/repository/payment/config/PaymentPollGroupChannelRepository.php create mode 100644 app/repository/payment/config/PaymentPollGroupRepository.php create mode 100644 app/repository/payment/config/PaymentTypeRepository.php create mode 100644 app/repository/payment/notify/NotifyTaskRepository.php create mode 100644 app/repository/payment/settlement/SettlementItemRepository.php create mode 100644 app/repository/payment/settlement/SettlementOrderRepository.php create mode 100644 app/repository/payment/trade/BizOrderRepository.php create mode 100644 app/repository/payment/trade/PayOrderRepository.php create mode 100644 app/repository/payment/trade/RefundOrderRepository.php create mode 100644 app/repository/system/config/SystemConfigRepository.php create mode 100644 app/repository/system/user/AdminUserRepository.php create mode 100644 app/route/admin.php create mode 100644 app/route/api.php create mode 100644 app/route/mer.php delete mode 100644 app/routes/admin.php delete mode 100644 app/routes/api.php delete mode 100644 app/routes/mer.php create mode 100644 app/service/account/funds/MerchantAccountCommandService.php create mode 100644 app/service/account/funds/MerchantAccountQueryService.php create mode 100644 app/service/account/funds/MerchantAccountService.php create mode 100644 app/service/account/ledger/MerchantAccountLedgerService.php create mode 100644 app/service/bootstrap/SystemBootstrapService.php create mode 100644 app/service/file/FileRecordCommandService.php create mode 100644 app/service/file/FileRecordQueryService.php create mode 100644 app/service/file/FileRecordService.php create mode 100644 app/service/file/StorageConfigService.php create mode 100644 app/service/file/storage/AbstractStorageDriver.php create mode 100644 app/service/file/storage/CosStorageDriver.php create mode 100644 app/service/file/storage/LocalStorageDriver.php create mode 100644 app/service/file/storage/OssStorageDriver.php create mode 100644 app/service/file/storage/RemoteUrlStorageDriver.php create mode 100644 app/service/file/storage/StorageDriverInterface.php create mode 100644 app/service/file/storage/StorageManager.php create mode 100644 app/service/merchant/MerchantCommandService.php create mode 100644 app/service/merchant/MerchantOverviewQueryService.php create mode 100644 app/service/merchant/MerchantQueryService.php create mode 100644 app/service/merchant/MerchantService.php create mode 100644 app/service/merchant/auth/MerchantAuthService.php create mode 100644 app/service/merchant/group/MerchantGroupService.php create mode 100644 app/service/merchant/policy/MerchantPolicyService.php create mode 100644 app/service/merchant/portal/MerchantPortalBalanceService.php create mode 100644 app/service/merchant/portal/MerchantPortalChannelQueryService.php create mode 100644 app/service/merchant/portal/MerchantPortalChannelService.php create mode 100644 app/service/merchant/portal/MerchantPortalCredentialCommandService.php create mode 100644 app/service/merchant/portal/MerchantPortalCredentialQueryService.php create mode 100644 app/service/merchant/portal/MerchantPortalCredentialService.php create mode 100644 app/service/merchant/portal/MerchantPortalFinanceService.php create mode 100644 app/service/merchant/portal/MerchantPortalProfileCommandService.php create mode 100644 app/service/merchant/portal/MerchantPortalProfileQueryService.php create mode 100644 app/service/merchant/portal/MerchantPortalProfileService.php create mode 100644 app/service/merchant/portal/MerchantPortalRoutePreviewService.php create mode 100644 app/service/merchant/portal/MerchantPortalService.php create mode 100644 app/service/merchant/portal/MerchantPortalSettlementService.php create mode 100644 app/service/merchant/portal/MerchantPortalSupportService.php create mode 100644 app/service/merchant/security/MerchantApiCredentialQueryService.php create mode 100644 app/service/merchant/security/MerchantApiCredentialService.php create mode 100644 app/service/ops/log/ChannelNotifyLogService.php create mode 100644 app/service/ops/log/PayCallbackLogService.php create mode 100644 app/service/ops/stat/ChannelDailyStatService.php create mode 100644 app/service/payment/compat/EpayCompatService.php create mode 100644 app/service/payment/config/PaymentChannelCommandService.php create mode 100644 app/service/payment/config/PaymentChannelQueryService.php create mode 100644 app/service/payment/config/PaymentChannelService.php create mode 100644 app/service/payment/config/PaymentPluginConfService.php create mode 100644 app/service/payment/config/PaymentPluginService.php create mode 100644 app/service/payment/config/PaymentPluginSyncService.php create mode 100644 app/service/payment/config/PaymentPollGroupBindService.php create mode 100644 app/service/payment/config/PaymentPollGroupChannelService.php create mode 100644 app/service/payment/config/PaymentPollGroupCommandService.php create mode 100644 app/service/payment/config/PaymentPollGroupQueryService.php create mode 100644 app/service/payment/config/PaymentPollGroupService.php create mode 100644 app/service/payment/config/PaymentTypeService.php create mode 100644 app/service/payment/order/PayOrderAttemptService.php create mode 100644 app/service/payment/order/PayOrderCallbackService.php create mode 100644 app/service/payment/order/PayOrderChannelDispatchService.php create mode 100644 app/service/payment/order/PayOrderFeeService.php create mode 100644 app/service/payment/order/PayOrderLifecycleService.php create mode 100644 app/service/payment/order/PayOrderQueryService.php create mode 100644 app/service/payment/order/PayOrderReportService.php create mode 100644 app/service/payment/order/PayOrderService.php create mode 100644 app/service/payment/order/RefundCreationService.php create mode 100644 app/service/payment/order/RefundLifecycleService.php create mode 100644 app/service/payment/order/RefundQueryService.php create mode 100644 app/service/payment/order/RefundReportService.php create mode 100644 app/service/payment/order/RefundService.php create mode 100644 app/service/payment/runtime/NotifyService.php create mode 100644 app/service/payment/runtime/PaymentPluginFactoryService.php create mode 100644 app/service/payment/runtime/PaymentPluginManager.php create mode 100644 app/service/payment/runtime/PaymentRouteResolverService.php create mode 100644 app/service/payment/runtime/PaymentRouteService.php create mode 100644 app/service/payment/settlement/SettlementLifecycleService.php create mode 100644 app/service/payment/settlement/SettlementOrderQueryService.php create mode 100644 app/service/payment/settlement/SettlementService.php create mode 100644 app/service/payment/trace/TradeTraceReportService.php create mode 100644 app/service/payment/trace/TradeTraceService.php create mode 100644 app/service/system/access/AdminAuthService.php create mode 100644 app/service/system/config/SystemConfigDefinitionService.php create mode 100644 app/service/system/config/SystemConfigPageService.php create mode 100644 app/service/system/config/SystemConfigRuntimeService.php create mode 100644 app/service/system/user/AdminUserService.php delete mode 100644 app/services/AdminService.php delete mode 100644 app/services/AuthService.php delete mode 100644 app/services/CaptchaService.php delete mode 100644 app/services/ChannelRoutePolicyService.php delete mode 100644 app/services/ChannelRouterService.php delete mode 100644 app/services/MenuService.php delete mode 100644 app/services/NotifyService.php delete mode 100644 app/services/PayNotifyService.php delete mode 100644 app/services/PayOrderService.php delete mode 100644 app/services/PayService.php delete mode 100644 app/services/PaymentStateService.php delete mode 100644 app/services/PluginService.php delete mode 100644 app/services/SystemConfigService.php delete mode 100644 app/services/SystemSettingService.php delete mode 100644 app/services/api/EpayProtocolService.php delete mode 100644 app/services/api/EpayService.php delete mode 100644 app/validation/EpayValidator.php delete mode 100644 app/validation/SystemConfigValidator.php create mode 100644 config/auth.php delete mode 100644 config/base-config/basic.json delete mode 100644 config/base-config/email.json delete mode 100644 config/base-config/permission.json delete mode 100644 config/base-config/tabs.json create mode 100644 config/dict.php delete mode 100644 config/jwt.php delete mode 100644 config/plugin/webman/redis-queue/app.php delete mode 100644 config/plugin/webman/redis-queue/command.php delete mode 100644 config/plugin/webman/redis-queue/log.php delete mode 100644 config/plugin/webman/redis-queue/process.php delete mode 100644 config/plugin/webman/redis-queue/redis.php delete mode 100644 config/system-file/dict.json delete mode 100644 config/system-file/menu.json delete mode 100644 config/system-file/menu.md create mode 100644 config/system_config.php delete mode 100644 database/20260320_align_current_schema.sql delete mode 100644 database/dev_seed.sql delete mode 100644 database/mvp_payment_tables.sql delete mode 100644 database/patch_callback_inbox.sql delete mode 100644 doc/epay.md delete mode 100644 doc/event.md delete mode 100644 doc/exception.md delete mode 100644 doc/project_overview.md delete mode 100644 doc/project_progress.md delete mode 100644 doc/skill.md delete mode 100644 doc/validation.md create mode 100644 docker-compose.yml create mode 100644 public/admin/index.html create mode 100644 public/cashier/index.html create mode 100644 public/mer/index.html create mode 100644 public/storage/uploads/image/2026/04/10/20260410105634_55bfa9769bdcf5b9.png create mode 100644 public/storage/uploads/image/2026/04/10/20260410110612_3d43ca03132fa6ea.svg create mode 100644 public/storage/uploads/image/2026/04/10/20260410132654_125e9fa2417c0005.svg create mode 100644 public/storage/uploads/image/2026/04/10/20260410132722_b07354346c3ab8b0.svg create mode 100644 public/storage/uploads/image/2026/04/10/20260410132828_1f3f2a6ef52e1bb1.svg create mode 100644 public/storage/uploads/image/2026/04/10/20260410144242_3209013f5b3255b0.jpg create mode 100644 support/Setup.php create mode 100644 support/functions.php delete mode 100644 support/helpers.php delete mode 100644 test.php diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..087a773 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# 数据库配置 +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=mpay +DB_USERNAME=root +DB_PASSWORD=your-password + +# Redis 配置 +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DATABASE=0 + +# 缓存配置 +CACHE_DRIVER=redis + +# JWT 配置 +AUTH_JWT_ISSUER=mpay +AUTH_JWT_LEEWAY=30 +AUTH_JWT_SECRET=change-me-jwt-secret-use-at-least-32-chars + +AUTH_ADMIN_JWT_SECRET=change-me-admin-jwt-secret-use-at-least-32-chars +AUTH_ADMIN_JWT_TTL=86400 +AUTH_ADMIN_JWT_REDIS_PREFIX=mpay:auth:admin: + +AUTH_MERCHANT_JWT_SECRET=change-me-merchant-jwt-secret-use-at-least-32-chars +AUTH_MERCHANT_JWT_TTL=86400 +AUTH_MERCHANT_JWT_REDIS_PREFIX=mpay:auth:merchant: diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..94ce154 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM php:8.3.22-cli-alpine + +RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" + +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ + && apk update --no-cache \ + && docker-php-source extract + +# install extensions +RUN docker-php-ext-install pdo pdo_mysql -j$(nproc) pcntl + +# enable opcache and pcntl +RUN docker-php-ext-enable opcache pcntl +RUN docker-php-source delete \ + rm -rf /var/cache/apk/* + +RUN mkdir -p /app +WORKDIR /app \ No newline at end of file diff --git a/app/command/EpayMapiTest.php b/app/command/EpayMapiTest.php new file mode 100644 index 0000000..41135a4 --- /dev/null +++ b/app/command/EpayMapiTest.php @@ -0,0 +1,667 @@ +setDescription('自动读取真实商户、路由和插件配置,测试 ePay mapi 是否正常调用并返回结果。') + ->addOption('live', null, InputOption::VALUE_NONE, '使用真实数据库并发起实际 mapi 调用') + ->addOption('merchant-id', null, InputOption::VALUE_OPTIONAL, '指定商户 ID') + ->addOption('merchant-no', null, InputOption::VALUE_OPTIONAL, '指定商户号') + ->addOption('type', null, InputOption::VALUE_OPTIONAL, '支付方式编码,默认 alipay', 'alipay') + ->addOption('money', null, InputOption::VALUE_OPTIONAL, '支付金额,单位元,默认 1.00', '1.00') + ->addOption('device', null, InputOption::VALUE_OPTIONAL, '设备类型,默认 pc', 'pc') + ->addOption('out-trade-no', null, InputOption::VALUE_OPTIONAL, '商户订单号,默认自动生成'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $output->writeln('epay mapi 烟雾测试'); + + if (!$this->optionBool($input, 'live', false)) { + $this->ensureDependencies(); + $output->writeln('[通过] 依赖检查正常,使用 --live 才会真正发起 mapi 请求。'); + + return self::SUCCESS; + } + + try { + $typeCode = trim($this->optionString($input, 'type', 'alipay')); + $money = $this->normalizeMoney($this->optionString($input, 'money', '1.00')); + $device = $this->normalizeDevice($this->optionString($input, 'device', 'pc')); + $merchantIdOption = $this->optionInt($input, 'merchant-id', 0); + $merchantNoOption = trim($this->optionString($input, 'merchant-no', '')); + $outTradeNo = $this->buildMerchantOrderNo(trim($this->optionString($input, 'out-trade-no', ''))); + + $context = $this->discoverContext($merchantIdOption, $merchantNoOption, $typeCode); + $merchant = $context['merchant']; + $credential = $context['credential']; + $paymentType = $context['payment_type']; + $route = $context['route']; + $siteUrl = $this->resolveSiteUrl(); + + $output->writeln(sprintf( + '商户: id=%d no=%s name=%s group_id=%d', + (int) $merchant->id, + (string) $merchant->merchant_no, + (string) $merchant->merchant_name, + (int) $merchant->group_id + )); + $output->writeln(sprintf( + '接口凭证: %s', + FormatHelper::maskCredentialValue((string) $credential->api_key) + )); + $output->writeln(sprintf( + '支付方式: %s(%d) 金额: %s 设备: %s', + (string) $paymentType->code, + (int) $paymentType->id, + $money, + $device + )); + $this->writeRouteSnapshot($output, $route); + + $payload = $this->buildPayload( + merchant: $merchant, + credential: $credential, + paymentType: $paymentType, + merchantOrderNo: $outTradeNo, + money: $money, + device: $device, + siteUrl: $siteUrl + ); + $controller = $this->resolve(EpayController::class); + $response = $controller->mapi($this->buildRequest($payload)); + $responseData = $this->decodeResponse($response->rawBody()); + $orderSnapshot = $this->loadOrderSnapshot((int) $merchant->id, $outTradeNo); + + $this->writeAttempt($output, $payload, $responseData, $orderSnapshot); + + $status = $this->classifyAttempt($responseData, $orderSnapshot); + return $status === 'pass' ? self::SUCCESS : self::FAILURE; + } catch (\Throwable $e) { + $output->writeln('[失败] ' . $this->formatThrowable($e)); + + return self::FAILURE; + } + } + + private function ensureDependencies(): void + { + $this->resolve(EpayController::class); + $this->resolve(MerchantRepository::class); + $this->resolve(MerchantApiCredentialRepository::class); + $this->resolve(PaymentTypeRepository::class); + $this->resolve(PaymentPollGroupBindRepository::class); + $this->resolve(PaymentPollGroupRepository::class); + $this->resolve(PaymentPollGroupChannelRepository::class); + $this->resolve(PaymentChannelRepository::class); + $this->resolve(BizOrderRepository::class); + $this->resolve(PayOrderRepository::class); + } + + /** + * @return array{merchant:Merchant,credential:MerchantApiCredential,payment_type:PaymentType,route:array} + */ + private function discoverContext(int $merchantIdOption, string $merchantNoOption, string $typeCode): array + { + /** @var PaymentTypeRepository $paymentTypeRepository */ + $paymentTypeRepository = $this->resolve(PaymentTypeRepository::class); + $paymentType = $paymentTypeRepository->findByCode($typeCode); + if (!$paymentType || (int) $paymentType->status !== 1) { + throw new RuntimeException('未找到可用的支付方式: ' . $typeCode); + } + + $merchant = $this->pickMerchant($merchantIdOption, $merchantNoOption); + $credential = $this->findMerchantCredential((int) $merchant->id); + if (!$credential) { + throw new RuntimeException('商户未开通有效接口凭证: ' . $merchant->merchant_no); + } + + $route = $this->buildRouteSnapshot((int) $merchant->group_id, (int) $paymentType->id); + if ($route === null) { + throw new RuntimeException('商户未配置可用路由: ' . $merchant->merchant_no); + } + + return [ + 'merchant' => $merchant, + 'credential' => $credential, + 'payment_type' => $paymentType, + 'route' => $route, + ]; + } + + private function pickMerchant(int $merchantIdOption, string $merchantNoOption): Merchant + { + /** @var MerchantRepository $merchantRepository */ + $merchantRepository = $this->resolve(MerchantRepository::class); + + if ($merchantIdOption > 0) { + $merchant = $merchantRepository->find($merchantIdOption); + if (!$merchant || (int) $merchant->status !== 1) { + throw new RuntimeException('指定商户不存在或未启用: ' . $merchantIdOption); + } + + if ($merchantNoOption !== '' && (string) $merchant->merchant_no !== $merchantNoOption) { + throw new RuntimeException('merchant-id 和 merchant-no 不匹配。'); + } + + return $merchant; + } + + if ($merchantNoOption !== '') { + $merchant = $merchantRepository->findByMerchantNo($merchantNoOption); + if (!$merchant || (int) $merchant->status !== 1) { + throw new RuntimeException('指定商户不存在或未启用: ' . $merchantNoOption); + } + + return $merchant; + } + + $merchant = $merchantRepository->enabledList(['id', 'merchant_no', 'merchant_name', 'group_id', 'status'])->first(); + if (!$merchant) { + throw new RuntimeException('未找到启用中的真实商户。'); + } + + return $merchant; + } + + private function findMerchantCredential(int $merchantId): ?MerchantApiCredential + { + /** @var MerchantApiCredentialRepository $repository */ + $repository = $this->resolve(MerchantApiCredentialRepository::class); + $credential = $repository->findByMerchantId($merchantId); + if (!$credential || (int) $credential->status !== 1) { + return null; + } + + return $credential; + } + + /** + * @return array{bind:mixed,poll_group:PaymentPollGroup,candidates:array>}|null + */ + private function buildRouteSnapshot(int $merchantGroupId, int $payTypeId): ?array + { + /** @var PaymentPollGroupBindRepository $bindRepository */ + $bindRepository = $this->resolve(PaymentPollGroupBindRepository::class); + /** @var PaymentPollGroupRepository $pollGroupRepository */ + $pollGroupRepository = $this->resolve(PaymentPollGroupRepository::class); + /** @var PaymentPollGroupChannelRepository $pollGroupChannelRepository */ + $pollGroupChannelRepository = $this->resolve(PaymentPollGroupChannelRepository::class); + /** @var PaymentChannelRepository $channelRepository */ + $channelRepository = $this->resolve(PaymentChannelRepository::class); + + $bind = $bindRepository->findActiveByMerchantGroupAndPayType($merchantGroupId, $payTypeId); + if (!$bind) { + return null; + } + + $pollGroup = $pollGroupRepository->find((int) $bind->poll_group_id); + if (!$pollGroup || (int) $pollGroup->status !== 1) { + return null; + } + + $candidateRows = $pollGroupChannelRepository->listByPollGroupId((int) $pollGroup->id); + if ($candidateRows->isEmpty()) { + return null; + } + + $channelIds = $candidateRows->pluck('channel_id')->all(); + $channels = $channelRepository->query() + ->whereIn('id', $channelIds) + ->where('status', 1) + ->get() + ->keyBy('id'); + + $candidates = []; + foreach ($candidateRows as $row) { + $channel = $channels->get((int) $row->channel_id); + if (!$channel) { + continue; + } + + if ((int) $channel->pay_type_id !== $payTypeId) { + continue; + } + + $candidates[] = [ + 'channel' => $channel, + 'poll_group_channel' => $row, + ]; + } + + if ($candidates === []) { + return null; + } + + return [ + 'bind' => $bind, + 'poll_group' => $pollGroup, + 'candidates' => $candidates, + ]; + } + + private function buildPayload( + Merchant $merchant, + MerchantApiCredential $credential, + PaymentType $paymentType, + string $merchantOrderNo, + string $money, + string $device, + string $siteUrl + ): array { + $siteUrl = rtrim($siteUrl, '/'); + $payload = [ + 'pid' => (int) $merchant->id, + 'key' => (string) $credential->api_key, + 'type' => (string) $paymentType->code, + 'out_trade_no' => $merchantOrderNo, + 'notify_url' => $siteUrl . '/epay/mapi/notify', + 'return_url' => $siteUrl . '/epay/mapi/return', + 'name' => trim(sprintf('mpay epay mapi smoke %s', (string) $merchant->merchant_name)), + 'money' => $money, + 'clientip' => '127.0.0.1', + 'device' => $device, + 'sign_type' => 'MD5', + ]; + $payload['sign'] = $this->signPayload($payload, (string) $credential->api_key); + + return $payload; + } + + private function classifyAttempt(array $responseData, array $orderSnapshot): string + { + $responseCode = (int) ($responseData['code'] ?? 0); + $payOrder = $orderSnapshot['pay_order'] ?? null; + $bizOrder = $orderSnapshot['biz_order'] ?? null; + + if ($responseCode !== 1) { + return $payOrder ? 'fail' : 'skip'; + } + + return ($payOrder && $bizOrder) ? 'pass' : 'fail'; + } + + private function writeRouteSnapshot(OutputInterface $output, array $route): void + { + /** @var PaymentPollGroup $pollGroup */ + $pollGroup = $route['poll_group']; + $candidates = $route['candidates']; + + $output->writeln(sprintf( + '路由: group_id=%d group_name=%s mode=%s', + (int) $pollGroup->id, + (string) $pollGroup->group_name, + $this->routeModeLabel((int) $pollGroup->route_mode) + )); + $output->writeln(sprintf(' 候选通道: %d 个', count($candidates))); + foreach ($candidates as $item) { + /** @var PaymentChannel $channel */ + $channel = $item['channel']; + $pollGroupChannel = $item['poll_group_channel']; + $output->writeln(sprintf( + ' - channel_id=%d name=%s default=%s sort_no=%d weight=%d mode=%s pay_type_id=%d plugin=%s', + (int) $channel->id, + (string) $channel->name, + (int) $pollGroupChannel->is_default === 1 ? 'yes' : 'no', + (int) $pollGroupChannel->sort_no, + (int) $pollGroupChannel->weight, + $this->channelModeLabel((int) $channel->channel_mode), + (int) $channel->pay_type_id, + (string) $channel->plugin_code + )); + } + } + + private function writeAttempt(OutputInterface $output, array $payload, array $responseData, array $orderSnapshot): void + { + $status = $this->classifyAttempt($responseData, $orderSnapshot); + $label = match ($status) { + 'pass' => '[通过]', + 'skip' => '[跳过]', + default => '[失败]', + }; + $payOrder = $orderSnapshot['pay_order'] ?? []; + $bizOrder = $orderSnapshot['biz_order'] ?? []; + $channel = $orderSnapshot['channel'] ?? []; + $paymentType = $orderSnapshot['payment_type'] ?? []; + + $output->writeln(sprintf('%s mapi - out_trade_no=%s', $label, $payload['out_trade_no'])); + $output->writeln(sprintf( + ' 请求: pid=%d type=%s money=%s device=%s clientip=%s', + (int) $payload['pid'], + (string) $payload['type'], + (string) $payload['money'], + (string) $payload['device'], + (string) $payload['clientip'] + )); + $output->writeln(sprintf( + ' 响应: code=%s msg=%s', + (string) ($responseData['code'] ?? ''), + (string) ($responseData['msg'] ?? '') + )); + foreach (['trade_no', 'payurl', 'origin_payurl', 'qrcode', 'urlscheme'] as $key) { + if (!isset($responseData[$key]) || $responseData[$key] === '') { + continue; + } + $output->writeln(sprintf(' 返回: %s=%s', $key, $this->stringifyValue($responseData[$key]))); + } + + if (!$bizOrder || !$payOrder) { + $output->writeln(' 订单: 未创建或未查到业务单/支付单'); + return; + } + + $output->writeln(sprintf( + ' 业务单: biz_no=%s status=%s active_pay_no=%s attempt_count=%d', + (string) ($bizOrder['biz_no'] ?? ''), + $this->orderStatusLabel((int) ($bizOrder['status'] ?? 0)), + (string) ($bizOrder['active_pay_no'] ?? ''), + (int) ($bizOrder['attempt_count'] ?? 0) + )); + $output->writeln(sprintf( + ' 支付单: pay_no=%s status=%s channel_id=%d channel=%s plugin=%s pay_type=%s', + (string) ($payOrder['pay_no'] ?? ''), + $this->orderStatusLabel((int) ($payOrder['status'] ?? 0)), + (int) ($payOrder['channel_id'] ?? 0), + (string) ($channel['name'] ?? ''), + (string) ($payOrder['plugin_code'] ?? ''), + (string) ($paymentType['code'] ?? '') + )); + $output->writeln(sprintf( + ' 支付单状态: channel_request_no=%s channel_order_no=%s channel_trade_no=%s', + (string) ($payOrder['channel_request_no'] ?? ''), + (string) ($payOrder['channel_order_no'] ?? ''), + (string) ($payOrder['channel_trade_no'] ?? '') + )); + $output->writeln(sprintf( + ' 失败信息: code=%s msg=%s', + (string) ($payOrder['channel_error_code'] ?? ''), + (string) ($payOrder['channel_error_msg'] ?? '') + )); + + $extJson = (array) ($payOrder['ext_json'] ?? []); + $summary = $this->summarizePayParamsSnapshot((array) ($extJson['pay_params_snapshot'] ?? [])); + if ($summary !== []) { + $output->writeln(' 插件返回:'); + $output->writeln(' ' . $this->formatJson($summary)); + } + } + + private function summarizePayParamsSnapshot(array $snapshot): array + { + if ($snapshot === []) { + return []; + } + + $summary = ['type' => (string) ($snapshot['type'] ?? '')]; + if (isset($snapshot['pay_product'])) { + $summary['pay_product'] = (string) $snapshot['pay_product']; + } + if (isset($snapshot['pay_action'])) { + $summary['pay_action'] = (string) $snapshot['pay_action']; + } + + switch ((string) ($snapshot['type'] ?? '')) { + case 'form': + $html = $this->stringifyValue($snapshot['html'] ?? ''); + $summary['html_length'] = strlen($html); + $summary['html_head'] = $this->limitString($this->normalizeWhitespace($html), 160); + break; + case 'qrcode': + $summary['qrcode_url'] = $this->stringifyValue($snapshot['qrcode_url'] ?? $snapshot['qrcode_data'] ?? ''); + break; + case 'urlscheme': + $summary['urlscheme'] = $this->stringifyValue($snapshot['urlscheme'] ?? $snapshot['order_str'] ?? ''); + break; + case 'url': + $summary['payurl'] = $this->stringifyValue($snapshot['payurl'] ?? ''); + $summary['origin_payurl'] = $this->stringifyValue($snapshot['origin_payurl'] ?? ''); + break; + default: + if (isset($snapshot['raw']) && is_array($snapshot['raw'])) { + $summary['raw_keys'] = array_values(array_map('strval', array_keys($snapshot['raw']))); + } + break; + } + + return $summary; + } + + private function routeModeLabel(int $routeMode): string + { + return RouteConstant::routeModeMap()[$routeMode] ?? '未知'; + } + + private function channelModeLabel(int $channelMode): string + { + return RouteConstant::channelModeMap()[$channelMode] ?? '未知'; + } + + private function orderStatusLabel(int $status): string + { + return TradeConstant::orderStatusMap()[$status] ?? '未知'; + } + + private function buildMerchantOrderNo(string $base): string + { + $base = trim($base); + if ($base !== '') { + return substr($base, 0, 64); + } + + return 'EPAY-MAPI-' . FormatHelper::timestamp(time(), 'YmdHis') . random_int(1000, 9999); + } + + private function signPayload(array $payload, string $key): string + { + $params = $payload; + unset($params['sign'], $params['sign_type'], $params['key']); + foreach ($params as $paramKey => $paramValue) { + if ($paramValue === '' || $paramValue === null) { + unset($params[$paramKey]); + } + } + + ksort($params); + $query = []; + foreach ($params as $paramKey => $paramValue) { + $query[] = $paramKey . '=' . $paramValue; + } + + return md5(implode('&', $query) . $key); + } + + private function buildRequest(array $payload): Request + { + $body = http_build_query($payload, '', '&', PHP_QUERY_RFC1738); + $siteUrl = $this->resolveSiteUrl(); + $host = parse_url($siteUrl, PHP_URL_HOST) ?: 'localhost'; + $port = parse_url($siteUrl, PHP_URL_PORT); + $hostHeader = $port ? sprintf('%s:%s', $host, $port) : $host; + + $rawRequest = implode("\r\n", [ + 'POST /mapi.php HTTP/1.1', + 'Host: ' . $hostHeader, + 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8', + 'Content-Length: ' . strlen($body), + 'Connection: close', + '', + $body, + ]); + + return new Request($rawRequest); + } + + private function loadOrderSnapshot(int $merchantId, string $merchantOrderNo): array + { + /** @var BizOrderRepository $bizOrderRepository */ + $bizOrderRepository = $this->resolve(BizOrderRepository::class); + /** @var PayOrderRepository $payOrderRepository */ + $payOrderRepository = $this->resolve(PayOrderRepository::class); + /** @var PaymentChannelRepository $channelRepository */ + $channelRepository = $this->resolve(PaymentChannelRepository::class); + /** @var PaymentTypeRepository $typeRepository */ + $typeRepository = $this->resolve(PaymentTypeRepository::class); + + $bizOrder = $bizOrderRepository->findByMerchantAndOrderNo($merchantId, $merchantOrderNo); + $payOrder = $bizOrder ? $payOrderRepository->findLatestByBizNo((string) $bizOrder->biz_no) : null; + $channel = $payOrder ? $channelRepository->find((int) $payOrder->channel_id) : null; + $paymentType = $payOrder ? $typeRepository->find((int) $payOrder->pay_type_id) : null; + + return [ + 'biz_order' => $bizOrder ? $bizOrder->toArray() : null, + 'pay_order' => $payOrder ? $payOrder->toArray() : null, + 'channel' => $channel ? $channel->toArray() : null, + 'payment_type' => $paymentType ? $paymentType->toArray() : null, + ]; + } + + private function resolveSiteUrl(): string + { + $siteUrl = trim((string) sys_config('site_url')); + return $siteUrl !== '' ? rtrim($siteUrl, '/') : 'http://localhost:8787'; + } + + private function normalizeMoney(string $money): string + { + $money = trim($money); + if ($money === '') { + return '1.00'; + } + + if (!preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) { + throw new RuntimeException('money 参数不合法: ' . $money); + } + + return number_format((float) $money, 2, '.', ''); + } + + private function normalizeDevice(string $device): string + { + $device = strtolower(trim($device)); + return $device !== '' ? $device : 'pc'; + } + + private function decodeResponse(string $body): array + { + $decoded = json_decode($body, true); + return is_array($decoded) ? $decoded : ['raw' => $body]; + } + + private function stringifyValue(mixed $value): string + { + if ($value === null) { + return ''; + } + if (is_string($value)) { + return trim($value); + } + if (is_int($value) || is_float($value) || is_bool($value)) { + return (string) $value; + } + if (is_array($value) || is_object($value)) { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return $json !== false ? $json : ''; + } + + return (string) $value; + } + + private function limitString(string $value, int $length): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + + return strlen($value) <= $length ? $value : substr($value, 0, $length) . '...'; + } + + private function normalizeWhitespace(string $value): string + { + return preg_replace('/\s+/', ' ', trim($value)) ?: ''; + } + + private function formatJson(mixed $value): string + { + return FormatHelper::json($value); + } + + private function formatThrowable(\Throwable $e): string + { + $data = method_exists($e, 'getData') ? $e->getData() : []; + $suffix = is_array($data) && $data !== [] ? ' ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : ''; + + return $e::class . ': ' . $e->getMessage() . $suffix; + } + + private function optionString(InputInterface $input, string $name, string $default = ''): string + { + $value = $input->getOption($name); + return $value === null || $value === false ? $default : (is_string($value) ? $value : (string) $value); + } + + private function optionInt(InputInterface $input, string $name, int $default = 0): int + { + $value = $input->getOption($name); + return is_numeric($value) ? (int) $value : $default; + } + + private function optionBool(InputInterface $input, string $name, bool $default = false): bool + { + $value = $input->getOption($name); + if ($value === null || $value === '') { + return $default; + } + + $filtered = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE); + + return $filtered === null ? $default : $filtered; + } + + private function resolve(string $class): object + { + try { + $instance = container_make($class, []); + } catch (\Throwable $e) { + throw new RuntimeException("无法解析 {$class}: " . $e->getMessage(), 0, $e); + } + + if (!is_object($instance)) { + throw new RuntimeException("解析后的 {$class} 不是对象。"); + } + + return $instance; + } +} diff --git a/app/command/MpayTest.php b/app/command/MpayTest.php new file mode 100644 index 0000000..41dd814 --- /dev/null +++ b/app/command/MpayTest.php @@ -0,0 +1,510 @@ +setDescription('运行支付、退款、清结算、余额和追踪烟雾测试。') + ->addOption('payment', null, InputOption::VALUE_NONE, '仅运行支付检查') + ->addOption('refund', null, InputOption::VALUE_NONE, '仅运行退款检查') + ->addOption('settlement', null, InputOption::VALUE_NONE, '仅运行清结算检查') + ->addOption('balance', null, InputOption::VALUE_NONE, '仅运行余额检查') + ->addOption('trace', null, InputOption::VALUE_NONE, '仅运行追踪检查') + ->addOption('all', null, InputOption::VALUE_NONE, '运行全部检查') + ->addOption('live', null, InputOption::VALUE_NONE, '在提供测试数据时运行真实数据库检查'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $cases = $this->resolveCases($input); + $live = (bool) $input->getOption('live'); + + $output->writeln('mpay 烟雾测试'); + $output->writeln('模式: ' . ($live ? '真实数据' : '依赖连通性')); + $output->writeln('测试项: ' . implode(', ', $cases)); + + $summary = []; + foreach ($cases as $case) { + $result = match ($case) { + 'payment' => $this->checkPayment($live), + 'refund' => $this->checkRefund($live), + 'settlement' => $this->checkSettlement($live), + 'balance' => $this->checkBalance($live), + 'trace' => $this->checkTrace($live), + default => ['status' => 'skip', 'message' => '未知测试项'], + }; + + $summary[] = $result['status']; + $this->writeResult($output, strtoupper($case), $result['status'], $result['message']); + } + + $failed = count(array_filter($summary, static fn (string $status) => $status === 'fail')); + $skipped = count(array_filter($summary, static fn (string $status) => $status === 'skip')); + $passed = count(array_filter($summary, static fn (string $status) => $status === 'pass')); + + $output->writeln(sprintf( + '汇总: %d 通过, %d 跳过, %d 失败', + $passed, + $skipped, + $failed + )); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } + + /** + * 根据命令行选项解析需要执行的测试项。 + */ + private function resolveCases(InputInterface $input): array + { + $selected = []; + foreach (['payment', 'refund', 'settlement', 'balance', 'trace'] as $case) { + if ((bool) $input->getOption($case)) { + $selected[] = $case; + } + } + + if ((bool) $input->getOption('all') || empty($selected)) { + return ['payment', 'refund', 'settlement', 'balance', 'trace']; + } + + return $selected; + } + + /** + * 检查支付链路。 + */ + private function checkPayment(bool $live): array + { + try { + $service = $this->resolve(PayOrderService::class); + $this->ensureMethod($service, 'preparePayAttempt'); + $this->ensureMethod($service, 'timeoutPayOrder'); + + if (!$live) { + return ['status' => 'pass', 'message' => '服务依赖连通性正常']; + } + + $merchantId = $this->envInt('MPAY_TEST_PAYMENT_MERCHANT_ID'); + $payTypeId = $this->envInt('MPAY_TEST_PAYMENT_TYPE_ID'); + $payAmount = $this->envInt('MPAY_TEST_PAYMENT_AMOUNT'); + $merchantOrderNo = $this->envString('MPAY_TEST_PAYMENT_ORDER_NO', $this->generateTestNo('PAY-TEST-')); + + if ($merchantId <= 0 || $payTypeId <= 0 || $payAmount <= 0) { + return ['status' => 'skip', 'message' => '缺少 MPAY_TEST_PAYMENT_* 测试配置']; + } + + $result = $service->preparePayAttempt([ + 'merchant_id' => $merchantId, + 'merchant_order_no' => $merchantOrderNo, + 'pay_type_id' => $payTypeId, + 'pay_amount' => $payAmount, + 'subject' => $this->envString('MPAY_TEST_PAYMENT_SUBJECT', 'mpay smoke payment'), + 'body' => $this->envString('MPAY_TEST_PAYMENT_BODY', 'mpay smoke payment'), + 'ext_json' => $this->envJson('MPAY_TEST_PAYMENT_EXT_JSON', []), + ]); + + $payOrder = $result['pay_order']; + $selectedChannel = $result['route']['selected_channel']['channel'] ?? null; + $message = sprintf( + '已创建支付单 pay_no=%s biz_no=%s channel_id=%s', + (string) $payOrder->pay_no, + (string) $result['biz_order']->biz_no, + $selectedChannel ? (string) $selectedChannel->id : '' + ); + + if ($this->envBool('MPAY_TEST_PAYMENT_MARK_TIMEOUT', false)) { + $service->timeoutPayOrder((string) $payOrder->pay_no, [ + 'timeout_at' => $this->envString('MPAY_TEST_PAYMENT_TIMEOUT_AT', FormatHelper::timestamp(time())), + 'reason' => $this->envString('MPAY_TEST_PAYMENT_TIMEOUT_REASON', 'mpay smoke timeout'), + ]); + $message .= ', 已标记超时'; + } elseif ($this->envBool('MPAY_TEST_PAYMENT_MARK_SUCCESS', false)) { + $service->markPaySuccess((string) $payOrder->pay_no, [ + 'fee_actual_amount' => $this->envInt('MPAY_TEST_PAYMENT_FEE_AMOUNT', (int) $payOrder->fee_estimated_amount), + 'channel_trade_no' => $this->envString('MPAY_TEST_PAYMENT_CHANNEL_TRADE_NO', $this->generateTestNo('CH-')), + 'channel_order_no' => $this->envString('MPAY_TEST_PAYMENT_CHANNEL_ORDER_NO', $this->generateTestNo('CO-')), + ]); + $message .= ', 已标记成功'; + } + + return ['status' => 'pass', 'message' => $message]; + } catch (\Throwable $e) { + return ['status' => 'fail', 'message' => $this->formatThrowable($e)]; + } + } + + /** + * 检查退款链路。 + */ + private function checkRefund(bool $live): array + { + try { + $service = $this->resolve(RefundService::class); + $this->ensureMethod($service, 'createRefund'); + $this->ensureMethod($service, 'markRefundProcessing'); + $this->ensureMethod($service, 'retryRefund'); + $this->ensureMethod($service, 'markRefundFailed'); + + if (!$live) { + return ['status' => 'pass', 'message' => '服务依赖连通性正常']; + } + + $payNo = $this->envString('MPAY_TEST_REFUND_PAY_NO'); + if ($payNo === '') { + return ['status' => 'skip', 'message' => '缺少 MPAY_TEST_REFUND_PAY_NO 测试配置']; + } + + $refund = $service->createRefund([ + 'pay_no' => $payNo, + 'merchant_refund_no' => $this->envString('MPAY_TEST_REFUND_NO', $this->generateTestNo('RFD-TEST-')), + 'reason' => $this->envString('MPAY_TEST_REFUND_REASON', 'mpay smoke refund'), + 'ext_json' => $this->envJson('MPAY_TEST_REFUND_EXT_JSON', []), + ]); + + $message = '已创建退款单 refund_no=' . (string) $refund->refund_no; + + if ($this->envBool('MPAY_TEST_REFUND_MARK_PROCESSING', false)) { + $service->markRefundProcessing((string) $refund->refund_no, [ + 'processing_at' => $this->envString('MPAY_TEST_REFUND_PROCESSING_AT', FormatHelper::timestamp(time())), + ]); + $message .= ', 已标记处理中'; + } elseif ($this->envBool('MPAY_TEST_REFUND_MARK_RETRY', false)) { + $service->retryRefund((string) $refund->refund_no, [ + 'processing_at' => $this->envString('MPAY_TEST_REFUND_RETRY_AT', FormatHelper::timestamp(time())), + ]); + $message .= ', 已标记重试'; + } elseif ($this->envBool('MPAY_TEST_REFUND_MARK_FAIL', false)) { + $service->markRefundFailed((string) $refund->refund_no, [ + 'failed_at' => $this->envString('MPAY_TEST_REFUND_FAILED_AT', FormatHelper::timestamp(time())), + 'last_error' => $this->envString('MPAY_TEST_REFUND_LAST_ERROR', 'mpay smoke refund failed'), + ]); + $message .= ', 已标记失败'; + } elseif ($this->envBool('MPAY_TEST_REFUND_MARK_SUCCESS', false)) { + $service->markRefundSuccess((string) $refund->refund_no, [ + 'channel_refund_no' => $this->envString('MPAY_TEST_REFUND_CHANNEL_NO', $this->generateTestNo('CR-')), + ]); + $message .= ', 已标记成功'; + } + + return ['status' => 'pass', 'message' => $message]; + } catch (\Throwable $e) { + return ['status' => 'fail', 'message' => $this->formatThrowable($e)]; + } + } + + /** + * 检查清结算链路。 + */ + private function checkSettlement(bool $live): array + { + try { + $service = $this->resolve(SettlementService::class); + $this->ensureMethod($service, 'createSettlementOrder'); + $this->ensureMethod($service, 'completeSettlement'); + $this->ensureMethod($service, 'failSettlement'); + + if (!$live) { + return ['status' => 'pass', 'message' => '服务依赖连通性正常']; + } + + $merchantId = 0; + $merchantGroupId = 0; + $channelId = 0; + $settleNo = $this->envString('MPAY_TEST_SETTLEMENT_NO', $this->generateTestNo('STL-TEST-')); + $items = $this->envJson('MPAY_TEST_SETTLEMENT_ITEMS_JSON', []); + + if (empty($items)) { + $payNo = $this->envString('MPAY_TEST_SETTLEMENT_PAY_NO'); + if ($payNo !== '') { + $payOrderRepository = $this->resolve(PayOrderRepository::class); + $payOrder = $payOrderRepository->findByPayNo($payNo); + if ($payOrder) { + $items = [[ + 'pay_no' => (string) $payOrder->pay_no, + 'refund_no' => '', + 'pay_amount' => (int) $payOrder->pay_amount, + 'fee_amount' => (int) $payOrder->fee_actual_amount, + 'refund_amount' => 0, + 'fee_reverse_amount' => 0, + 'net_amount' => max(0, (int) $payOrder->pay_amount - (int) $payOrder->fee_actual_amount), + 'item_status' => TradeConstant::SETTLEMENT_STATUS_PENDING, + ]]; + $merchantId = (int) $payOrder->merchant_id; + $merchantGroupId = (int) $payOrder->merchant_group_id; + $channelId = (int) $payOrder->channel_id; + } + } + } + + if ($merchantId <= 0) { + $merchantId = $this->envInt('MPAY_TEST_SETTLEMENT_MERCHANT_ID'); + } + if ($merchantGroupId <= 0) { + $merchantGroupId = $this->envInt('MPAY_TEST_SETTLEMENT_MERCHANT_GROUP_ID'); + } + if ($channelId <= 0) { + $channelId = $this->envInt('MPAY_TEST_SETTLEMENT_CHANNEL_ID'); + } + + if ($merchantId <= 0 || $merchantGroupId <= 0 || $channelId <= 0) { + return ['status' => 'skip', 'message' => '缺少 MPAY_TEST_SETTLEMENT_* 测试配置']; + } + + if (empty($items)) { + $items = [[ + 'pay_no' => '', + 'refund_no' => '', + 'pay_amount' => $this->envInt('MPAY_TEST_SETTLEMENT_GROSS_AMOUNT', 100), + 'fee_amount' => $this->envInt('MPAY_TEST_SETTLEMENT_FEE_AMOUNT', 0), + 'refund_amount' => $this->envInt('MPAY_TEST_SETTLEMENT_REFUND_AMOUNT', 0), + 'fee_reverse_amount' => $this->envInt('MPAY_TEST_SETTLEMENT_FEE_REVERSE_AMOUNT', 0), + 'net_amount' => $this->envInt('MPAY_TEST_SETTLEMENT_NET_AMOUNT', 100), + 'item_status' => TradeConstant::SETTLEMENT_STATUS_PENDING, + ]]; + } + + $settlement = $service->createSettlementOrder([ + 'settle_no' => $settleNo, + 'merchant_id' => $merchantId, + 'merchant_group_id' => $merchantGroupId, + 'channel_id' => $channelId, + 'cycle_type' => $this->envInt('MPAY_TEST_SETTLEMENT_CYCLE_TYPE', TradeConstant::SETTLEMENT_CYCLE_OTHER), + 'cycle_key' => $this->envString('MPAY_TEST_SETTLEMENT_CYCLE_KEY', FormatHelper::timestamp(time(), 'Y-m-d')), + 'accounted_amount' => $this->envInt('MPAY_TEST_SETTLEMENT_ACCOUNTED_AMOUNT', 0), + 'status' => TradeConstant::SETTLEMENT_STATUS_PENDING, + 'ext_json' => $this->envJson('MPAY_TEST_SETTLEMENT_EXT_JSON', []), + ], $items); + + $message = '已创建清结算单 settle_no=' . (string) $settlement->settle_no; + + if ($this->envBool('MPAY_TEST_SETTLEMENT_FAIL', false)) { + $service->failSettlement( + (string) $settlement->settle_no, + $this->envString('MPAY_TEST_SETTLEMENT_FAIL_REASON', 'mpay smoke settlement fail') + ); + $message .= ', 已标记失败'; + } elseif ($this->envBool('MPAY_TEST_SETTLEMENT_COMPLETE', false)) { + $service->completeSettlement((string) $settlement->settle_no); + $message .= ', 已完成入账'; + } + + return ['status' => 'pass', 'message' => $message]; + } catch (\Throwable $e) { + return ['status' => 'fail', 'message' => $this->formatThrowable($e)]; + } + } + + /** + * 检查余额链路。 + */ + private function checkBalance(bool $live): array + { + try { + $accountService = $this->resolve(MerchantAccountService::class); + $this->ensureMethod($accountService, 'getBalanceSnapshot'); + $this->resolve(MerchantService::class); + + if (!$live) { + return ['status' => 'pass', 'message' => '服务依赖连通性正常']; + } + + $merchantId = $this->envInt('MPAY_TEST_BALANCE_MERCHANT_ID'); + if ($merchantId <= 0) { + $merchantNo = $this->envString('MPAY_TEST_BALANCE_MERCHANT_NO'); + if ($merchantNo !== '') { + $merchantService = $this->resolve(MerchantService::class); + $merchant = $merchantService->findEnabledMerchantByNo($merchantNo); + $merchantId = (int) $merchant->id; + } + } + + if ($merchantId <= 0) { + return ['status' => 'skip', 'message' => '缺少 MPAY_TEST_BALANCE_* 测试配置']; + } + + $snapshot = $accountService->getBalanceSnapshot($merchantId); + + return [ + 'status' => 'pass', + 'message' => sprintf( + '余额 merchant_id=%d 可用=%d 冻结=%d', + (int) $snapshot['merchant_id'], + (int) $snapshot['available_balance'], + (int) $snapshot['frozen_balance'] + ), + ]; + } catch (\Throwable $e) { + return ['status' => 'fail', 'message' => $this->formatThrowable($e)]; + } + } + + /** + * 检查统一追踪链路。 + */ + private function checkTrace(bool $live): array + { + try { + $service = $this->resolve(TradeTraceService::class); + $this->ensureMethod($service, 'queryByTraceNo'); + + if (!$live) { + return ['status' => 'pass', 'message' => '服务依赖连通性正常']; + } + + $traceNo = $this->envString('MPAY_TEST_TRACE_NO'); + if ($traceNo === '') { + return ['status' => 'skip', 'message' => '缺少 MPAY_TEST_TRACE_NO 测试配置']; + } + + $result = $service->queryByTraceNo($traceNo); + if (empty($result)) { + return ['status' => 'fail', 'message' => '追踪结果为空']; + } + + $message = sprintf( + 'trace_no=%s 支付=%d 退款=%d 清结算=%d 流水=%d', + (string) ($result['resolved_trace_no'] ?? $traceNo), + count($result['pay_orders'] ?? []), + count($result['refund_orders'] ?? []), + count($result['settlement_orders'] ?? []), + count($result['account_ledgers'] ?? []) + ); + + return ['status' => 'pass', 'message' => $message]; + } catch (\Throwable $e) { + return ['status' => 'fail', 'message' => $this->formatThrowable($e)]; + } + } + + /** + * 从容器中解析指定类实例。 + */ + private function resolve(string $class): object + { + try { + $instance = container_make($class, []); + } catch (\Throwable $e) { + throw new RuntimeException("无法解析 {$class}: " . $e->getMessage(), 0, $e); + } + + if (!is_object($instance)) { + throw new RuntimeException("解析后的 {$class} 不是对象。"); + } + + return $instance; + } + + /** + * 检查实例是否包含指定方法。 + */ + private function ensureMethod(object $instance, string $method): void + { + if (!method_exists($instance, $method)) { + throw new RuntimeException(sprintf('未找到方法 %s::%s。', $instance::class, $method)); + } + } + + /** + * 读取字符串环境变量。 + */ + private function envString(string $key, string $default = ''): string + { + $value = env($key, $default); + + return is_string($value) ? $value : (string) $value; + } + + /** + * 读取整数环境变量。 + */ + private function envInt(string $key, int $default = 0): int + { + $value = env($key, null); + + return is_numeric($value) ? (int) $value : $default; + } + + /** + * 读取布尔环境变量。 + */ + private function envBool(string $key, bool $default = false): bool + { + $value = env($key, null); + if ($value === null || $value === '') { + return $default; + } + + $filtered = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE); + + return $filtered === null ? $default : $filtered; + } + + /** + * 读取结构化环境变量。 + */ + private function envJson(string $key, array $default = []): array + { + $value = trim($this->envString($key)); + if ($value === '') { + return $default; + } + + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : $default; + } + + /** + * 生成测试编号。 + */ + private function generateTestNo(string $prefix): string + { + return $prefix . FormatHelper::timestamp(time(), 'YmdHis') . random_int(1000, 9999); + } + + /** + * 将异常格式化为可读文本。 + */ + private function formatThrowable(\Throwable $e): string + { + $data = method_exists($e, 'getData') ? $e->getData() : []; + $suffix = $data ? ' ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : ''; + + return $e::class . ': ' . $e->getMessage() . $suffix; + } + + /** + * 输出单个测试项的执行结果。 + */ + private function writeResult(OutputInterface $output, string $case, string $status, string $message): void + { + $label = match ($status) { + 'pass' => '[通过]', + 'skip' => '[跳过]', + default => '[失败]', + }; + + $output->writeln(sprintf('%s %s - %s', $label, $case, $message)); + } +} + diff --git a/app/command/SystemConfigSync.php b/app/command/SystemConfigSync.php new file mode 100644 index 0000000..fcf8d30 --- /dev/null +++ b/app/command/SystemConfigSync.php @@ -0,0 +1,70 @@ +setDescription('同步 config/system_config.php 中定义的系统配置默认值到数据库。'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + /** @var SystemConfigDefinitionService $definitionService */ + $definitionService = container_make(SystemConfigDefinitionService::class, []); + /** @var SystemConfigRepository $repository */ + $repository = container_make(SystemConfigRepository::class, []); + /** @var SystemConfigRuntimeService $runtimeService */ + $runtimeService = container_make(SystemConfigRuntimeService::class, []); + + $tabs = $definitionService->tabs(); + $written = 0; + + foreach ($tabs as $tab) { + $groupCode = (string) ($tab['key'] ?? ''); + foreach ((array) ($tab['rules'] ?? []) as $rule) { + if (!is_array($rule)) { + continue; + } + + $configKey = strtolower(trim((string) ($rule['field'] ?? ''))); + if ($configKey === '') { + continue; + } + + $repository->updateOrCreate( + ['config_key' => $configKey], + [ + 'group_code' => $groupCode, + 'config_value' => (string) ($rule['value'] ?? ''), + ] + ); + + $written++; + } + } + + $runtimeService->refresh(); + + $output->writeln(sprintf('系统配置同步完成,写入 %d 项。', $written)); + + return self::SUCCESS; + } catch (\Throwable $e) { + $output->writeln('系统配置同步失败:' . $e->getMessage() . ''); + + return self::FAILURE; + } + } +} diff --git a/app/common/base/BaseController.php b/app/common/base/BaseController.php index 085a634..97426f4 100644 --- a/app/common/base/BaseController.php +++ b/app/common/base/BaseController.php @@ -2,49 +2,44 @@ namespace app\common\base; +use app\exception\ValidationException; +use support\Context; use support\Request; use support\Response; /** - * 控制器基础父类 + * HTTP 层基础控制器。 * - * 约定统一的 JSON 返回结构: - * { - * "code": 200, - * "message": "success", - * "data": ... - * } + * 统一提供响应封装、参数校验、请求上下文读取等通用能力。 */ class BaseController { /** - * 成功返回 + * 返回成功响应。 */ - protected function success(mixed $data = null, string $message = 'success', int $code = 200): Response + protected function success(mixed $data = null, string $message = '操作成功', int $code = 200): Response { return json([ - 'code' => $code, + 'code' => $code, 'msg' => $message, - 'data' => $data, + 'data' => $data, ]); } /** - * 失败返回 + * 返回失败响应。 */ - protected function fail(string $message = 'error', int $code = 500, mixed $data = null): Response + protected function fail(string $message = '操作失败', int $code = 500, mixed $data = null): Response { return json([ - 'code' => $code, + 'code' => $code, 'msg' => $message, - 'data' => $data, + 'data' => $data, ]); } /** - * 统一分页返回结构 - * - * @param mixed $paginator Laravel/Eloquent paginator + * 返回统一分页响应。 */ protected function page(mixed $paginator): Response { @@ -58,28 +53,76 @@ class BaseController } return $this->success([ - 'list' => $paginator->items(), - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'size' => $paginator->perPage(), + 'list' => $paginator->items(), + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'size' => $paginator->perPage(), ]); } /** - * 获取当前登录用户的 token 载荷 + * 通过校验器类验证请求数据。 * - * 从 AuthMiddleware 注入的用户信息中获取 + * @param class-string $validatorClass */ - protected function currentUser(Request $request): ?array + protected function validated(array $data, string $validatorClass, ?string $scene = null): array { - return $request->user ?? null; + $validator = $validatorClass::make($data); + + if ($scene !== null) { + $validator = $validator->withScene($scene); + } + + return $validator + ->withException(ValidationException::class) + ->validate(); } /** - * 获取当前登录用户ID + * 获取中间件预处理后的标准化参数。 */ - protected function currentUserId(Request $request): int + protected function payload(Request $request): array { - return (int) ($request->userId ?? 0); + $payload = (array) $request->all(); + $normalized = Context::get('mpay.normalized_input', []); + + if (is_array($normalized) && !empty($normalized)) { + $payload = array_replace($payload, $normalized); + } + + return $payload; } + + /** + * 读取请求属性。 + */ + protected function requestAttribute(Request $request, string $key, mixed $default = null): mixed + { + return Context::get($key, $default); + } + + /** + * 获取中间件注入的当前管理员 ID。 + */ + protected function currentAdminId(Request $request): int + { + return (int) $this->requestAttribute($request, 'auth.admin_id', 0); + } + + /** + * 获取中间件注入的当前商户 ID。 + */ + protected function currentMerchantId(Request $request): int + { + return (int) $this->requestAttribute($request, 'auth.merchant_id', 0); + } + + /** + * 获取中间件注入的当前商户编号。 + */ + protected function currentMerchantNo(Request $request): string + { + return (string) $this->requestAttribute($request, 'auth.merchant_no', ''); + } + } diff --git a/app/common/base/BaseModel.php b/app/common/base/BaseModel.php index e50ea75..2a9c80a 100644 --- a/app/common/base/BaseModel.php +++ b/app/common/base/BaseModel.php @@ -2,35 +2,50 @@ namespace app\common\base; +use app\common\util\FormatHelper; +use DateTimeInterface; use support\Model; /** - * 所有业务模型的基础父类 + * 所有业务模型的基础父类。 + * + * 统一主键、时间戳和默认批量赋值策略。 */ class BaseModel extends Model { /** - * 约定所有主键字段名 + * 默认主键字段名。 * * @var string */ protected $primaryKey = 'id'; /** - * 是否自动维护 created_at / updated_at + * 是否自动维护 created_at / updated_at。 * - * 大部分业务表都有这两个字段,如不需要可在子类里覆盖为 false。 + * 大部分业务表都包含这两个字段,如有例外可在子类中覆盖为 false。 * * @var bool */ - public $timestamps = false; + public $timestamps = true; /** - * 默认不禁止任何字段的批量赋值 + * 默认仅保护主键,其他字段按子类 fillable 约束。 * - * 建议在具体模型中按需设置 $fillable 或 $guarded。 + * 建议在具体模型中显式声明 $fillable。 * * @var array */ - protected $guarded = []; + protected $guarded = ['id']; + + /** + * 统一模型时间字段的 JSON 输出格式。 + * + * 避免前端收到 ISO8601(如 2026-04-02T01:50:40.000000Z)这类不直观的时间串, + * 统一改为后台常用的本地展示格式。 + */ + protected function serializeDate(DateTimeInterface $date): string + { + return FormatHelper::dateTime($date); + } } diff --git a/app/common/base/BasePayment.php b/app/common/base/BasePayment.php index f62f188..31037d7 100644 --- a/app/common/base/BasePayment.php +++ b/app/common/base/BasePayment.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace app\common\base; -use app\common\contracts\PayPluginInterface; -use app\exceptions\PaymentException; +use app\common\interface\PayPluginInterface; +use app\exception\PaymentException; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; use Psr\Http\Message\ResponseInterface; @@ -21,7 +21,7 @@ use support\Log; * - 子类可在 `init()` 中配置第三方 SDK(例如 yansongda/pay)或读取必填参数。 * * 约定: - * - 这里的 `$channelConfig` 来源通常是 `ma_pay_channel.config_json`,属于“通道级配置”。 + * - 这里的 `$channelConfig` 来源通常是 `ma_payment_plugin_conf.config`,并附带通道维度上下文。 * - 业务级入参(如订单号、金额、回调地址等)不要混进 `$channelConfig`,应从 `pay()` 的 `$order` 参数获取。 */ abstract class BasePayment implements PayPluginInterface @@ -108,6 +108,12 @@ abstract class BasePayment implements PayPluginInterface return $this->paymentInfo['link'] ?? ''; } + /** 获取版本号 */ + public function getVersion(): string + { + return $this->paymentInfo['version'] ?? ''; + } + // ==================== 能力声明 ==================== /** diff --git a/app/common/base/BaseRepository.php b/app/common/base/BaseRepository.php index 136f3fe..4244b06 100644 --- a/app/common/base/BaseRepository.php +++ b/app/common/base/BaseRepository.php @@ -2,65 +2,194 @@ namespace app\common\base; +use Illuminate\Database\UniqueConstraintViolationException; use support\Model; +use support\Db; /** - * 仓储层基础父类 + * 仓储层基础类。 * - * 封装单表常用的 CRUD / 分页操作,具体仓储继承后可扩展业务查询。 + * 封装通用 CRUD、条件查询、加锁查询和分页查询能力。 */ abstract class BaseRepository { /** - * @var Model + * 当前仓储绑定的模型实例。 */ protected Model $model; - + + /** + * 构造函数,绑定模型实例。 + */ public function __construct(Model $model) { $this->model = $model; } /** - * 根据主键查询 + * 获取查询构造器。 */ - public function find(int $id, array $columns = ['*']): ?Model + public function query() { - return $this->model->newQuery()->find($id, $columns); + return $this->model->newQuery(); } /** - * 新建记录 + * 按主键查询记录。 + */ + public function find(int|string $id, array $columns = ['*']): ?Model + { + return $this->query()->find($id, $columns); + } + + /** + * 新增记录。 */ public function create(array $data): Model { - return $this->model->newQuery()->create($data); + return $this->query()->create($data); } /** - * 按主键更新 + * 按主键更新记录。 */ - public function updateById(int $id, array $data): bool + public function updateById(int|string $id, array $data): bool { - return (bool) $this->model->newQuery()->whereKey($id)->update($data); + return (bool) $this->query()->whereKey($id)->update($data); } /** - * 按主键删除 + * 按唯一键更新记录。 */ - public function deleteById(int $id): bool + public function updateByKey(int|string $key, array $data): bool { - return (bool) $this->model->newQuery()->whereKey($id)->delete(); + return (bool) $this->query()->whereKey($key)->update($data); } /** - * 简单分页查询示例 + * 按条件批量更新记录。 + */ + public function updateWhere(array $where, array $data): int + { + $query = $this->query(); + + if (!empty($where)) { + $query->where($where); + } + + return (int) $query->update($data); + } + + /** + * 按主键删除记录。 + */ + public function deleteById(int|string $id): bool + { + return (bool) $this->query()->whereKey($id)->delete(); + } + + /** + * 按条件批量删除记录。 + */ + public function deleteWhere(array $where): int + { + $query = $this->query(); + + if (!empty($where)) { + $query->where($where); + } + + return (int) $query->delete(); + } + + /** + * 按条件获取首条记录。 + */ + public function firstBy(array $where = [], array $columns = ['*']): ?Model + { + $query = $this->query(); + + if (!empty($where)) { + $query->where($where); + } + + return $query->first($columns); + } + + /** + * 先查后更,不存在则创建。 + */ + public function updateOrCreate(array $where, array $data = []): Model + { + if ($where === []) { + return $this->create($data); + } + + return Db::transaction(function () use ($where, $data): Model { + $query = $this->query()->lockForUpdate(); + $query->where($where); + + /** @var Model|null $model */ + $model = $query->first(); + if ($model) { + $model->fill($data); + $model->save(); + + return $model->refresh(); + } + + try { + return $this->create(array_merge($where, $data)); + } catch (UniqueConstraintViolationException $e) { + $model = $this->firstBy($where); + if (!$model) { + throw $e; + } + + $model->fill($data); + $model->save(); + + return $model->refresh(); + } + }); + } + + /** + * 按条件统计数量。 + */ + public function countBy(array $where = []): int + { + $query = $this->query(); + + if (!empty($where)) { + $query->where($where); + } + + return (int) $query->count(); + } + + /** + * 判断条件下是否存在记录。 + */ + public function existsBy(array $where = []): bool + { + $query = $this->query(); + + if (!empty($where)) { + $query->where($where); + } + + return $query->exists(); + } + + /** + * 分页查询。 * - * @param array $where ['字段' => 值],值为 null / '' 时会被忽略 + * @param array $where 条件数组,空值会被忽略 */ public function paginate(array $where = [], int $page = 1, int $pageSize = 10, array $columns = ['*']) { - $query = $this->model->newQuery(); + $query = $this->query(); if (!empty($where)) { $query->where($where); diff --git a/app/common/base/BaseService.php b/app/common/base/BaseService.php index 6089baa..649fa7b 100644 --- a/app/common/base/BaseService.php +++ b/app/common/base/BaseService.php @@ -2,23 +2,172 @@ namespace app\common\base; +use app\common\util\FormatHelper; use support\Db; +use Throwable; /** - * 业务服务层基础父类 + * 业务服务层基础类。 + * + * 统一承载业务单号生成、时间获取和事务封装等通用能力。 */ class BaseService { /** - * 事务封装 + * 生成业务单号。 * - * 使用方式: - * $this->transaction(function () { ... }); + * 适用于 biz_no / pay_no / refund_no / settle_no / notify_no / ledger_no 等场景。 + * 默认使用时间前缀 + 随机数,保证可读性和基本唯一性。 + */ + protected function generateNo(string $prefix = ''): string + { + $time = FormatHelper::timestamp(time(), 'YmdHis'); + $rand = (string) random_int(100000, 999999); + + return $prefix === '' ? $time . $rand : $prefix . $time . $rand; + } + + /** + * 获取当前时间字符串。 + * + * 统一返回 `Y-m-d H:i:s` 格式,便于数据库写入和日志输出。 + */ + protected function now(): string + { + return FormatHelper::timestamp(time()); + } + + /** + * 金额格式化,单位为元。 + */ + protected function formatAmount(int $amount): string + { + return FormatHelper::amount($amount); + } + + /** + * 金额格式化,0 时显示不限。 + */ + protected function formatAmountOrUnlimited(int $amount): string + { + return FormatHelper::amountOrUnlimited($amount); + } + + /** + * 次数格式化,0 时显示不限。 + */ + protected function formatCountOrUnlimited(int $count): string + { + return FormatHelper::countOrUnlimited($count); + } + + /** + * 费率格式化,单位为百分点。 + */ + protected function formatRate(int $basisPoints): string + { + return FormatHelper::rate($basisPoints); + } + + /** + * 延迟格式化。 + */ + protected function formatLatency(int $latencyMs): string + { + return FormatHelper::latency($latencyMs); + } + + /** + * 日期格式化。 + */ + protected function formatDate(mixed $value, string $emptyText = ''): string + { + return FormatHelper::date($value, $emptyText); + } + + /** + * 日期时间格式化。 + */ + protected function formatDateTime(mixed $value, string $emptyText = ''): string + { + return FormatHelper::dateTime($value, $emptyText); + } + + /** + * JSON 文本格式化。 + */ + protected function formatJson(mixed $value, string $emptyText = ''): string + { + return FormatHelper::json($value, $emptyText); + } + + /** + * 映射表文本转换。 + */ + protected function textFromMap(int $value, array $map, string $default = '未知'): string + { + return FormatHelper::textFromMap($value, $map, $default); + } + + /** + * 接口凭证明文脱敏。 + */ + protected function maskCredentialValue(string $credentialValue, bool $maskShortValue = true): string + { + return FormatHelper::maskCredentialValue($credentialValue, $maskShortValue); + } + + /** + * 将模型或对象归一化成数组。 + */ + protected function normalizeModel(mixed $value): ?array + { + return FormatHelper::normalizeModel($value); + } + + /** + * 事务封装。 + * + * 适合单次数据库事务,不包含自动重试逻辑。 + * + * @param callable $callback 事务体 + * @return mixed */ protected function transaction(callable $callback) { - return Db::connection()->transaction(function () use ($callback) { + return Db::transaction(function () use ($callback) { return $callback(); }); } + + /** + * 支持重试的事务封装。 + * + * 适合余额冻结、扣减、状态推进和幂等写入等容易发生锁冲突的场景。 + */ + protected function transactionRetry(callable $callback, int $attempts = 3, int $sleepMs = 50) + { + $attempts = max(1, $attempts); + + beginning: + try { + return $this->transaction($callback); + } catch (Throwable $e) { + $message = strtolower($e->getMessage()); + $retryable = str_contains($message, 'deadlock') + || str_contains($message, 'lock wait timeout') + || str_contains($message, 'try restarting transaction'); + + if (--$attempts > 0 && $retryable) { + if ($sleepMs > 0) { + usleep($sleepMs * 1000); + } + + goto beginning; + } + + throw $e; + } + } + } diff --git a/app/common/constant/AuthConstant.php b/app/common/constant/AuthConstant.php new file mode 100644 index 0000000..dda5096 --- /dev/null +++ b/app/common/constant/AuthConstant.php @@ -0,0 +1,39 @@ + 'MD5', + ]; + } + + public static function guardMap(): array + { + return [ + self::GUARD_ADMIN => 'admin', + self::GUARD_MERCHANT => 'merchant', + ]; + } +} diff --git a/app/common/constant/CommonConstant.php b/app/common/constant/CommonConstant.php new file mode 100644 index 0000000..e5fd09b --- /dev/null +++ b/app/common/constant/CommonConstant.php @@ -0,0 +1,31 @@ + '禁用', + self::STATUS_ENABLED => '启用', + ]; + } + + public static function yesNoMap(): array + { + return [ + self::NO => '否', + self::YES => '是', + ]; + } +} diff --git a/app/common/constant/FileConstant.php b/app/common/constant/FileConstant.php new file mode 100644 index 0000000..3b91cdf --- /dev/null +++ b/app/common/constant/FileConstant.php @@ -0,0 +1,135 @@ + '上传', + self::SOURCE_REMOTE_URL => '远程导入', + ]; + } + + public static function visibilityMap(): array + { + return [ + self::VISIBILITY_PUBLIC => '公开', + self::VISIBILITY_PRIVATE => '私有', + ]; + } + + public static function sceneMap(): array + { + return [ + self::SCENE_IMAGE => '图片', + self::SCENE_CERTIFICATE => '证书', + self::SCENE_TEXT => '文本', + self::SCENE_OTHER => '其他', + ]; + } + + public static function storageEngineMap(): array + { + return [ + self::STORAGE_LOCAL => '本地存储', + self::STORAGE_ALIYUN_OSS => '阿里云 OSS', + self::STORAGE_TENCENT_COS => '腾讯云 COS', + self::STORAGE_REMOTE_URL => '远程引用', + ]; + } + + public static function selectableStorageEngineMap(): array + { + return [ + self::STORAGE_LOCAL => '本地存储', + self::STORAGE_ALIYUN_OSS => '阿里云 OSS', + self::STORAGE_TENCENT_COS => '腾讯云 COS', + ]; + } + + public static function imageExtensionMap(): array + { + return [ + 'jpg' => true, + 'jpeg' => true, + 'png' => true, + 'gif' => true, + 'webp' => true, + 'bmp' => true, + 'svg' => true, + ]; + } + + public static function certificateExtensionMap(): array + { + return [ + 'pem' => true, + 'crt' => true, + 'cer' => true, + 'key' => true, + 'p12' => true, + 'pfx' => true, + ]; + } + + public static function textExtensionMap(): array + { + return [ + 'txt' => true, + 'log' => true, + 'csv' => true, + 'json' => true, + 'xml' => true, + 'md' => true, + 'ini' => true, + 'conf' => true, + 'yaml' => true, + 'yml' => true, + ]; + } + + public static function defaultAllowedExtensions(): array + { + return array_keys(self::imageExtensionMap() + self::certificateExtensionMap() + self::textExtensionMap()); + } +} diff --git a/app/common/constant/LedgerConstant.php b/app/common/constant/LedgerConstant.php new file mode 100644 index 0000000..a4783dc --- /dev/null +++ b/app/common/constant/LedgerConstant.php @@ -0,0 +1,54 @@ + '支付冻结', + self::BIZ_TYPE_PAY_DEDUCT => '支付扣费', + self::BIZ_TYPE_PAY_RELEASE => '支付释放', + self::BIZ_TYPE_SETTLEMENT_CREDIT => '清算入账', + self::BIZ_TYPE_REFUND_REVERSE => '退款冲正', + self::BIZ_TYPE_MANUAL_ADJUST => '人工调整', + ]; + } + + public static function eventTypeMap(): array + { + return [ + self::EVENT_TYPE_CREATE => '创建', + self::EVENT_TYPE_SUCCESS => '成功', + self::EVENT_TYPE_FAILED => '失败', + self::EVENT_TYPE_REVERSE => '冲正', + ]; + } + + public static function directionMap(): array + { + return [ + self::DIRECTION_IN => '入账', + self::DIRECTION_OUT => '出账', + ]; + } +} diff --git a/app/common/constant/MerchantConstant.php b/app/common/constant/MerchantConstant.php new file mode 100644 index 0000000..f1bd5d9 --- /dev/null +++ b/app/common/constant/MerchantConstant.php @@ -0,0 +1,35 @@ + '个人', + self::TYPE_COMPANY => '企业', + self::TYPE_OTHER => '其他', + ]; + } + + public static function riskLevelMap(): array + { + return [ + self::RISK_LOW => '低', + self::RISK_MEDIUM => '中', + self::RISK_HIGH => '高', + ]; + } +} diff --git a/app/common/constant/NotifyConstant.php b/app/common/constant/NotifyConstant.php new file mode 100644 index 0000000..fac52df --- /dev/null +++ b/app/common/constant/NotifyConstant.php @@ -0,0 +1,70 @@ + '异步通知', + self::NOTIFY_TYPE_QUERY => '查单', + ]; + } + + public static function callbackTypeMap(): array + { + return [ + self::CALLBACK_TYPE_ASYNC => '异步通知', + self::CALLBACK_TYPE_SYNC => '同步返回', + ]; + } + + public static function verifyStatusMap(): array + { + return [ + self::VERIFY_STATUS_UNKNOWN => '未知', + self::VERIFY_STATUS_SUCCESS => '成功', + self::VERIFY_STATUS_FAILED => '失败', + ]; + } + + public static function processStatusMap(): array + { + return [ + self::PROCESS_STATUS_PENDING => '待处理', + self::PROCESS_STATUS_SUCCESS => '成功', + self::PROCESS_STATUS_FAILED => '失败', + ]; + } + + public static function taskStatusMap(): array + { + return [ + self::TASK_STATUS_PENDING => '待通知', + self::TASK_STATUS_SUCCESS => '成功', + self::TASK_STATUS_FAILED => '失败', + ]; + } +} diff --git a/app/common/constant/RouteConstant.php b/app/common/constant/RouteConstant.php new file mode 100644 index 0000000..404ce7d --- /dev/null +++ b/app/common/constant/RouteConstant.php @@ -0,0 +1,55 @@ + '平台代收', + self::CHANNEL_TYPE_MERCHANT_SELF => '商户自有', + ]; + } + + public static function channelModeMap(): array + { + return [ + self::CHANNEL_MODE_COLLECT => '代收', + self::CHANNEL_MODE_SELF => '自收', + ]; + } + + public static function routeModeMap(): array + { + return [ + self::ROUTE_MODE_ORDER => '顺序依次轮询', + self::ROUTE_MODE_WEIGHTED => '权重随机轮询', + self::ROUTE_MODE_FIRST_AVAILABLE => '默认启用通道', + ]; + } +} diff --git a/app/common/constant/TradeConstant.php b/app/common/constant/TradeConstant.php new file mode 100644 index 0000000..a16fe0a --- /dev/null +++ b/app/common/constant/TradeConstant.php @@ -0,0 +1,157 @@ + 'D0', + self::SETTLEMENT_CYCLE_D1 => 'D1', + self::SETTLEMENT_CYCLE_D7 => 'D7', + self::SETTLEMENT_CYCLE_T1 => 'T1', + self::SETTLEMENT_CYCLE_OTHER => 'OTHER', + ]; + } + + public static function orderStatusMap(): array + { + return [ + self::ORDER_STATUS_CREATED => '待创建', + self::ORDER_STATUS_PAYING => '支付中', + self::ORDER_STATUS_SUCCESS => '成功', + self::ORDER_STATUS_FAILED => '失败', + self::ORDER_STATUS_CLOSED => '关闭', + self::ORDER_STATUS_TIMEOUT => '超时', + ]; + } + + public static function feeStatusMap(): array + { + return [ + self::FEE_STATUS_NONE => '无', + self::FEE_STATUS_FROZEN => '冻结', + self::FEE_STATUS_DEDUCTED => '已扣', + self::FEE_STATUS_RELEASED => '已释放', + ]; + } + + public static function settlementStatusMap(): array + { + return [ + self::SETTLEMENT_STATUS_NONE => '无', + self::SETTLEMENT_STATUS_PENDING => '待清算', + self::SETTLEMENT_STATUS_SETTLED => '已清算', + self::SETTLEMENT_STATUS_REVERSED => '已冲正', + ]; + } + + public static function refundStatusMap(): array + { + return [ + self::REFUND_STATUS_CREATED => '待创建', + self::REFUND_STATUS_PROCESSING => '处理中', + self::REFUND_STATUS_SUCCESS => '成功', + self::REFUND_STATUS_FAILED => '失败', + self::REFUND_STATUS_CLOSED => '关闭', + ]; + } + + public static function orderMutableStatuses(): array + { + return [ + self::ORDER_STATUS_CREATED, + self::ORDER_STATUS_PAYING, + ]; + } + + public static function orderTerminalStatuses(): array + { + return [ + self::ORDER_STATUS_SUCCESS, + self::ORDER_STATUS_FAILED, + self::ORDER_STATUS_CLOSED, + self::ORDER_STATUS_TIMEOUT, + ]; + } + + public static function isOrderTerminalStatus(int $status): bool + { + return in_array($status, self::orderTerminalStatuses(), true); + } + + public static function refundMutableStatuses(): array + { + return [ + self::REFUND_STATUS_CREATED, + self::REFUND_STATUS_PROCESSING, + self::REFUND_STATUS_FAILED, + ]; + } + + public static function refundTerminalStatuses(): array + { + return [ + self::REFUND_STATUS_SUCCESS, + self::REFUND_STATUS_CLOSED, + ]; + } + + public static function isRefundTerminalStatus(int $status): bool + { + return in_array($status, self::refundTerminalStatuses(), true); + } + + public static function settlementMutableStatuses(): array + { + return [ + self::SETTLEMENT_STATUS_PENDING, + ]; + } + + public static function settlementTerminalStatuses(): array + { + return [ + self::SETTLEMENT_STATUS_SETTLED, + self::SETTLEMENT_STATUS_REVERSED, + ]; + } + + public static function isSettlementTerminalStatus(int $status): bool + { + return in_array($status, self::settlementTerminalStatuses(), true); + } +} diff --git a/app/common/constants/YesNo.php b/app/common/constants/YesNo.php deleted file mode 100644 index 332e20c..0000000 --- a/app/common/constants/YesNo.php +++ /dev/null @@ -1,15 +0,0 @@ - $channelConfig */ public function init(array $channelConfig): void; - /** 插件代码(与 ma_pay_plugin.plugin_code 对应) */ + /** 插件代码(与 ma_payment_plugin.code 对应) */ public function getCode(): string; /** 插件名称(用于后台展示) */ public function getName(): string; + /** 插件作者名称(用于后台展示) */ + public function getAuthorName(): string; + + /** 插件作者链接(用于后台展示) */ + public function getAuthorLink(): string; + + /** 插件版本号(用于后台展示) */ + public function getVersion(): string; + /** * 插件声明支持的支付方式编码 * @@ -39,6 +48,9 @@ interface PayPluginInterface */ public function getEnabledPayTypes(): array; + /** 插件声明支持的转账方式编码 */ + public function getEnabledTransferTypes(): array; + /** * 插件配置结构(用于后台渲染表单/校验) * diff --git a/app/common/contracts/PaymentInterface.php b/app/common/interface/PaymentInterface.php similarity index 75% rename from app/common/contracts/PaymentInterface.php rename to app/common/interface/PaymentInterface.php index 1de889a..d871607 100644 --- a/app/common/contracts/PaymentInterface.php +++ b/app/common/interface/PaymentInterface.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace app\common\contracts; +namespace app\common\interface; -use app\exceptions\PaymentException; +use app\exception\PaymentException; use support\Request; use support\Response; @@ -24,11 +24,12 @@ interface PaymentInterface * 统一下单 * * @param array $order 订单数据,通常包含: - * - order_id: 系统订单号 - * - mch_no: 商户号 - * - amount: 金额(元) + * - order_id: 系统支付单号,建议直接使用 pay_no + * - amount: 金额(分) * - subject: 商品标题 * - body: 商品描述 + * - callback_url: 第三方异步回调地址(回调到本系统) + * - return_url: 支付完成跳转地址 * @return array 支付参数,需包含 pay_params、chan_order_no、chan_trade_no * @throws PaymentException 下单失败、渠道异常、参数错误等 */ @@ -59,9 +60,9 @@ interface PaymentInterface * 申请退款 * * @param array $order 退款数据,通常包含: - * - order_id: 原订单号 + * - order_id: 原支付单号 * - chan_order_no: 渠道订单号 - * - refund_amount: 退款金额 + * - refund_amount: 退款金额(分) * - refund_no: 退款单号 * @return array 退款结果,通常包含 success、chan_refund_no、msg * @throws PaymentException 退款失败、渠道异常等 @@ -75,15 +76,24 @@ interface PaymentInterface * * @param Request $request 支付渠道的异步通知请求(GET/POST 参数) * @return array 解析结果,通常包含: - * - status: 支付状态 - * - pay_order_id: 系统订单号 + * - success: 是否支付成功 + * - status: 插件解析出的渠道状态文本 + * - pay_order_id: 系统支付单号 * - chan_trade_no: 渠道交易号 - * - amount: 支付金额 + * - chan_order_no: 渠道订单号 + * - amount: 支付金额(分) + * - paid_at: 支付成功时间 * @throws PaymentException 验签失败、数据异常等 */ public function notify(Request $request): array; + /** + * 回调处理成功时返回给第三方的平台响应。 + */ public function notifySuccess(): string|Response; + /** + * 回调处理失败时返回给第三方的平台响应。 + */ public function notifyFail(): string|Response; } diff --git a/app/common/middleware/Cors.php b/app/common/middleware/Cors.php index e3c86f7..c354229 100644 --- a/app/common/middleware/Cors.php +++ b/app/common/middleware/Cors.php @@ -2,18 +2,20 @@ namespace app\common\middleware; -use Webman\MiddlewareInterface; -use Webman\Http\Response; use Webman\Http\Request; +use Webman\Http\Response; +use Webman\MiddlewareInterface; /** - * 全局跨域中间件 - * 处理前后端分离项目中的跨域请求问题 + * 全局跨域中间件。 + * + * 统一处理预检请求和跨域响应头。 */ class Cors implements MiddlewareInterface { /** - * 处理请求 + * 处理请求。 + * * @param Request $request 请求对象 * @param callable $handler 下一个中间件处理函数 * @return Response 响应对象 diff --git a/app/common/middleware/StaticFile.php b/app/common/middleware/StaticFile.php deleted file mode 100644 index c9605bd..0000000 --- a/app/common/middleware/StaticFile.php +++ /dev/null @@ -1,42 +0,0 @@ - - * @copyright walkor - * @link http://www.workerman.net/ - * @license http://www.opensource.org/licenses/mit-license.php MIT License - */ - -namespace app\common\middleware; - -use Webman\MiddlewareInterface; -use Webman\Http\Response; -use Webman\Http\Request; - -/** - * Class StaticFile - * @package app\middleware - */ -class StaticFile implements MiddlewareInterface -{ - public function process(Request $request, callable $handler): Response - { - // Access to files beginning with. Is prohibited - if (strpos($request->path(), '/.') !== false) { - return response('

403 forbidden

', 403); - } - /** @var Response $response */ - $response = $handler($request); - // Add cross domain HTTP header - /*$response->withHeaders([ - 'Access-Control-Allow-Origin' => '*', - 'Access-Control-Allow-Credentials' => 'true', - ]);*/ - return $response; - } -} diff --git a/app/common/payment/AlipayPayment.php b/app/common/payment/AlipayPayment.php index 38f42d2..c78a39c 100644 --- a/app/common/payment/AlipayPayment.php +++ b/app/common/payment/AlipayPayment.php @@ -5,8 +5,9 @@ declare(strict_types=1); namespace app\common\payment; use app\common\base\BasePayment; -use app\common\contracts\PaymentInterface; -use app\exceptions\PaymentException; +use app\common\interface\PaymentInterface; +use app\common\util\FormatHelper; +use app\exception\PaymentException; use Psr\Http\Message\ResponseInterface; use support\Request; use support\Response; @@ -16,43 +17,83 @@ use Yansongda\Supports\Collection; /** * 支付宝支付插件(基于 yansongda/pay ~3.7) * - * 支持:web(电脑网站)、h5(手机网站)、scan(扫码)、app(APP 支付) + * 支持:web(电脑网站)、h5(手机网站)、app(APP 支付)、mini(小程序)、pos(刷卡)、scan(扫码)、transfer(转账) * * 通道配置:app_id, app_secret_cert, app_public_cert_path, alipay_public_cert_path, - * alipay_root_cert_path, notify_url, return_url, mode(0正式/1沙箱) + * alipay_root_cert_path, mode(0正式/1沙箱) */ class AlipayPayment extends BasePayment implements PaymentInterface { + private const PRODUCT_WEB = 'alipay_web'; + private const PRODUCT_H5 = 'alipay_h5'; + private const PRODUCT_APP = 'alipay_app'; + private const PRODUCT_MINI = 'alipay_mini'; + private const PRODUCT_POS = 'alipay_pos'; + private const PRODUCT_SCAN = 'alipay_scan'; + private const PRODUCT_TRANSFER = 'alipay_transfer'; + + private const DEFAULT_ENABLED_PRODUCTS = [ + self::PRODUCT_H5, + ]; + + private const PRODUCT_ACTION_MAP = [ + self::PRODUCT_WEB => 'web', + self::PRODUCT_H5 => 'h5', + self::PRODUCT_APP => 'app', + self::PRODUCT_MINI => 'mini', + self::PRODUCT_POS => 'pos', + self::PRODUCT_SCAN => 'scan', + self::PRODUCT_TRANSFER => 'transfer', + ]; + + private const ACTION_PRODUCT_MAP = [ + 'web' => self::PRODUCT_WEB, + 'h5' => self::PRODUCT_H5, + 'app' => self::PRODUCT_APP, + 'mini' => self::PRODUCT_MINI, + 'pos' => self::PRODUCT_POS, + 'scan' => self::PRODUCT_SCAN, + 'transfer' => self::PRODUCT_TRANSFER, + ]; + protected array $paymentInfo = [ 'code' => 'alipay', 'name' => '支付宝直连', - 'author' => '', - 'link' => '', - 'pay_types' => ['alipay'], - 'transfer_types' => [], + 'author' => '技术老胡', + 'link' => 'https://www.baidu.com', + 'version' => '1.0.0', + 'pay_types' => ['alipay', 'alipay_app'], + 'transfer_types' => ['alipay', 'alipay_app'], 'config_schema' => [ - 'fields' => [ - ['field' => 'app_id', 'label' => '应用ID', 'type' => 'text', 'required' => true], - ['field' => 'app_secret_cert', 'label' => '应用私钥', 'type' => 'textarea', 'required' => true], - ['field' => 'app_public_cert_path', 'label' => '应用公钥证书路径', 'type' => 'text', 'required' => true], - ['field' => 'alipay_public_cert_path', 'label' => '支付宝公钥证书路径', 'type' => 'text', 'required' => true], - ['field' => 'alipay_root_cert_path', 'label' => '支付宝根证书路径', 'type' => 'text', 'required' => true], - ['field' => 'notify_url', 'label' => '异步通知地址', 'type' => 'text', 'required' => true], - ['field' => 'return_url', 'label' => '同步跳转地址', 'type' => 'text', 'required' => false], - ['field' => 'mode', 'label' => '环境', 'type' => 'select', 'options' => [['value' => '0', 'label' => '正式'], ['value' => '1', 'label' => '沙箱']]], + ["type" => "input", "field" => "app_id", "title" => "应用ID", "value" => "", "props" => ["placeholder" => "请输入应用ID"], "validate" => [["required" => true, "message" => "应用ID不能为空"]]], + ["type" => "textarea", "field" => "app_secret_cert", "title" => "应用私钥", "value" => "", "props" => ["placeholder" => "请输入应用私钥", "rows" => 4], "validate" => [["required" => true, "message" => "应用私钥不能为空"]]], + ["type" => "input", "field" => "app_public_cert_path", "title" => "应用公钥证书路径", "value" => "", "props" => ["placeholder" => "请输入应用公钥证书路径"], "validate" => [["required" => true, "message" => "应用公钥证书路径不能为空"]]], + ["type" => "input", "field" => "alipay_public_cert_path", "title" => "支付宝公钥证书路径", "value" => "", "props" => ["placeholder" => "请输入支付宝公钥证书路径"], "validate" => [["required" => true, "message" => "支付宝公钥证书路径不能为空"]]], + ["type" => "input", "field" => "alipay_root_cert_path", "title" => "支付宝根证书路径", "value" => "", "props" => ["placeholder" => "请输入支付宝根证书路径"], "validate" => [["required" => true, "message" => "支付宝根证书路径不能为空"]]], + [ + "type" => "checkbox", + "field" => "enabled_products", + "title" => "已开通产品", + "value" => self::DEFAULT_ENABLED_PRODUCTS, + "options" => [ + ["value" => self::PRODUCT_WEB, "label" => "web - 网页支付"], + ["value" => self::PRODUCT_H5, "label" => "h5 - H5 支付"], + ["value" => self::PRODUCT_APP, "label" => "app - APP 支付"], + ["value" => self::PRODUCT_MINI, "label" => "mini - 小程序支付"], + ["value" => self::PRODUCT_POS, "label" => "pos - 刷卡支付"], + ["value" => self::PRODUCT_SCAN, "label" => "scan - 扫码支付"], + ["value" => self::PRODUCT_TRANSFER, "label" => "transfer - 账户转账"], + ], + "validate" => [["required" => true, "message" => "请至少选择一个已开通产品"]], ], + ["type" => "select", "field" => "mode", "title" => "环境", "value" => "0", "props" => ["placeholder" => "请选择环境"], "options" => [["value" => "0", "label" => "正式"], ["value" => "1", "label" => "沙箱"]]], ], ]; - private const PRODUCT_WEB = 'alipay_web'; - private const PRODUCT_H5 = 'alipay_h5'; - private const PRODUCT_SCAN = 'alipay_scan'; - private const PRODUCT_APP = 'alipay_app'; - public function init(array $channelConfig): void { parent::init($channelConfig); - Pay::config([ + $config = [ 'alipay' => [ 'default' => [ 'app_id' => $this->getConfig('app_id', ''), @@ -65,47 +106,229 @@ class AlipayPayment extends BasePayment implements PaymentInterface 'mode' => (int)($this->getConfig('mode', Pay::MODE_NORMAL)), ], ], - ]); + ]; + Pay::config(array_merge($config, ['_force' => true])); } - private function chooseProduct(array $order): string + private function chooseProduct(array $order, bool $validateEnabled = true): string { - $enabled = $this->channelConfig['enabled_products'] ?? ['alipay_web', 'alipay_h5', 'alipay_scan']; - $env = $order['_env'] ?? 'pc'; - $map = ['pc' => self::PRODUCT_WEB, 'h5' => self::PRODUCT_H5, 'alipay' => self::PRODUCT_APP]; - $prefer = $map[$env] ?? self::PRODUCT_WEB; + $enabled = $this->normalizeEnabledProducts($this->channelConfig['enabled_products'] ?? self::DEFAULT_ENABLED_PRODUCTS); + $explicit = $this->resolveExplicitProduct($order); + if ($explicit !== null) { + if ($validateEnabled && !in_array($explicit, $enabled, true)) { + throw new PaymentException('支付宝产品未开通:' . $this->productAction($explicit), 402); + } + + return $explicit; + } + + $env = strtolower((string) ($order['_env'] ?? $order['device'] ?? 'pc')); + $map = [ + 'pc' => self::PRODUCT_WEB, + 'web' => self::PRODUCT_WEB, + 'desktop' => self::PRODUCT_WEB, + 'mobile' => self::PRODUCT_H5, + 'h5' => self::PRODUCT_H5, + 'wechat' => self::PRODUCT_H5, + 'qq' => self::PRODUCT_H5, + 'alipay' => self::PRODUCT_APP, + 'app' => self::PRODUCT_APP, + 'mini' => self::PRODUCT_MINI, + 'pos' => self::PRODUCT_POS, + 'scan' => self::PRODUCT_SCAN, + 'transfer' => self::PRODUCT_TRANSFER, + ]; + $prefer = $map[$env] ?? self::PRODUCT_WEB; + + $payTypeCode = strtolower((string) ($order['pay_type_code'] ?? $order['type_code'] ?? '')); + if ($payTypeCode === 'alipay_app') { + $prefer = self::PRODUCT_APP; + } + + if (!$validateEnabled) { + return $prefer; + } + return in_array($prefer, $enabled, true) ? $prefer : ($enabled[0] ?? self::PRODUCT_WEB); } + private function normalizeEnabledProducts(mixed $products): array + { + if (is_string($products)) { + $decoded = json_decode($products, true); + $products = is_array($decoded) ? $decoded : [$products]; + } + + if (!is_array($products)) { + return self::DEFAULT_ENABLED_PRODUCTS; + } + + $normalized = []; + foreach ($products as $product) { + $value = strtolower(trim((string) $product)); + if ($value !== '') { + $normalized[] = $value; + } + } + + $normalized = array_values(array_unique($normalized)); + + return $normalized !== [] ? $normalized : self::DEFAULT_ENABLED_PRODUCTS; + } + + private function resolveExplicitProduct(array $order): ?string + { + $context = $this->collectOrderContext($order); + $candidates = [ + $context['pay_product'] ?? null, + $context['product'] ?? null, + $context['alipay_product'] ?? null, + $context['pay_action'] ?? null, + $context['action'] ?? null, + ]; + + foreach ($candidates as $candidate) { + $product = $this->normalizeProductCode($candidate); + if ($product !== null) { + return $product; + } + } + + return null; + } + + private function normalizeProductCode(mixed $value): ?string + { + $value = strtolower(trim((string) $value)); + if ($value === '') { + return null; + } + + if (isset(self::ACTION_PRODUCT_MAP[$value])) { + return self::ACTION_PRODUCT_MAP[$value]; + } + + if (isset(self::PRODUCT_ACTION_MAP[$value])) { + return $value; + } + + return null; + } + + private function productAction(string $product): string + { + return self::PRODUCT_ACTION_MAP[$product] ?? $product; + } + + private function collectOrderContext(array $order): array + { + $context = $order; + $extra = isset($order['extra']) && is_array($order['extra']) ? $order['extra'] : []; + if ($extra !== []) { + $context = array_merge($context, $extra); + } + + $param = $this->normalizeParamBag($context['param'] ?? null); + if ($param !== []) { + $context = array_merge($context, $param); + } + + return $context; + } + + private function normalizeParamBag(mixed $param): array + { + if (is_array($param)) { + return $param; + } + + if (is_string($param) && $param !== '') { + $decoded = json_decode($param, true); + if (is_array($decoded)) { + return $decoded; + } + + parse_str($param, $parsed); + if (is_array($parsed) && $parsed !== []) { + return $parsed; + } + } + + return []; + } + + private function buildBasePayParams(array $params): array + { + $base = [ + 'out_trade_no' => (string) ($params['out_trade_no'] ?? ''), + 'total_amount' => FormatHelper::amount((int) ($params['amount'] ?? 0)), + 'subject' => (string) ($params['subject'] ?? ''), + ]; + + $body = (string) ($params['body'] ?? ''); + if ($body !== '') { + $base['body'] = $body; + } + + $returnUrl = (string) ($params['_return_url'] ?? ''); + if ($returnUrl !== '') { + $base['_return_url'] = $returnUrl; + } + + $notifyUrl = (string) ($params['_notify_url'] ?? ''); + if ($notifyUrl !== '') { + $base['_notify_url'] = $notifyUrl; + } + + return $base; + } + + private function extractCollectionValue(Collection $result, array $keys, mixed $default = ''): mixed + { + foreach ($keys as $key) { + $value = $result->get($key); + if ($value !== null && $value !== '') { + return $value; + } + } + + return $default; + } + public function pay(array $order): array { - $orderId = $order['order_id'] ?? $order['mch_no'] ?? ''; - $amount = (float)($order['amount'] ?? 0); + $orderId = (string) ($order['order_id'] ?? $order['pay_no'] ?? ''); + $amount = (int) ($order['amount'] ?? 0); $subject = (string)($order['subject'] ?? ''); + $body = (string)($order['body'] ?? ''); $extra = $order['extra'] ?? []; - $returnUrl = $extra['return_url'] ?? $this->getConfig('return_url', ''); - $notifyUrl = $this->getConfig('notify_url', ''); + $returnUrl = (string) ($order['return_url'] ?? $extra['return_url'] ?? $this->getConfig('return_url', '')); + $notifyUrl = (string) ($order['callback_url'] ?? $this->getConfig('notify_url', '')); - $params = [ + if ($orderId === '' || $amount <= 0 || $subject === '') { + throw new PaymentException('支付宝下单参数不完整', 402); + } + + $params = $this->buildBasePayParams([ 'out_trade_no' => $orderId, - 'total_amount' => sprintf('%.2f', $amount), - 'subject' => $subject, - ]; - if ($returnUrl !== '') { - $params['_return_url'] = $returnUrl; - } - if ($notifyUrl !== '') { - $params['_notify_url'] = $notifyUrl; - } + 'amount' => $amount, + 'subject' => $subject, + 'body' => $body, + '_return_url' => $returnUrl, + '_notify_url' => $notifyUrl, + ]); $product = $this->chooseProduct($order); try { return match ($product) { - self::PRODUCT_WEB => $this->doWeb($params), - self::PRODUCT_H5 => $this->doH5($params), - self::PRODUCT_SCAN => $this->doScan($params), - self::PRODUCT_APP => $this->doApp($params), + self::PRODUCT_WEB => $this->doWeb($params), + self::PRODUCT_H5 => $this->doH5($params), + self::PRODUCT_SCAN => $this->doScan($params), + self::PRODUCT_APP => $this->doApp($params), + self::PRODUCT_MINI => $this->doMini($params, $order), + self::PRODUCT_POS => $this->doPos($params, $order), + self::PRODUCT_TRANSFER => $this->doTransfer($params, $order), default => throw new PaymentException('不支持的支付宝产品:' . $product, 402), }; } catch (PaymentException $e) { @@ -120,7 +343,16 @@ class AlipayPayment extends BasePayment implements PaymentInterface $response = Pay::alipay()->web($params); $body = $response instanceof ResponseInterface ? (string)$response->getBody() : ''; return [ - 'pay_params' => ['type' => 'form', 'method' => 'POST', 'action' => '', 'html' => $body], + 'pay_product' => self::PRODUCT_WEB, + 'pay_action' => $this->productAction(self::PRODUCT_WEB), + 'pay_params' => [ + 'type' => 'form', + 'method' => 'POST', + 'action' => '', + 'html' => $body, + 'pay_product' => self::PRODUCT_WEB, + 'pay_action' => $this->productAction(self::PRODUCT_WEB), + ], 'chan_order_no' => $params['out_trade_no'], 'chan_trade_no' => '', ]; @@ -135,7 +367,16 @@ class AlipayPayment extends BasePayment implements PaymentInterface $response = Pay::alipay()->h5($params); $body = $response instanceof ResponseInterface ? (string)$response->getBody() : ''; return [ - 'pay_params' => ['type' => 'form', 'method' => 'POST', 'action' => '', 'html' => $body], + 'pay_product' => self::PRODUCT_H5, + 'pay_action' => $this->productAction(self::PRODUCT_H5), + 'pay_params' => [ + 'type' => 'form', + 'method' => 'POST', + 'action' => '', + 'html' => $body, + 'pay_product' => self::PRODUCT_H5, + 'pay_action' => $this->productAction(self::PRODUCT_H5), + ], 'chan_order_no' => $params['out_trade_no'], 'chan_trade_no' => '', ]; @@ -147,7 +388,15 @@ class AlipayPayment extends BasePayment implements PaymentInterface $result = Pay::alipay()->scan($params); $qrCode = $result->get('qr_code', ''); return [ - 'pay_params' => ['type' => 'qrcode', 'qrcode_url' => $qrCode, 'qrcode_data' => $qrCode], + 'pay_product' => self::PRODUCT_SCAN, + 'pay_action' => $this->productAction(self::PRODUCT_SCAN), + 'pay_params' => [ + 'type' => 'qrcode', + 'qrcode_url' => $qrCode, + 'qrcode_data' => $qrCode, + 'pay_product' => self::PRODUCT_SCAN, + 'pay_action' => $this->productAction(self::PRODUCT_SCAN), + ], 'chan_order_no' => $params['out_trade_no'], 'chan_trade_no' => $result->get('trade_no', ''), ]; @@ -159,28 +408,161 @@ class AlipayPayment extends BasePayment implements PaymentInterface $result = Pay::alipay()->app($params); $orderStr = $result->get('order_string', ''); return [ - 'pay_params' => ['type' => 'jsapi', 'order_str' => $orderStr, 'urlscheme' => $orderStr], + 'pay_product' => self::PRODUCT_APP, + 'pay_action' => $this->productAction(self::PRODUCT_APP), + 'pay_params' => [ + 'type' => 'jsapi', + 'order_str' => $orderStr, + 'urlscheme' => $orderStr, + 'pay_product' => self::PRODUCT_APP, + 'pay_action' => $this->productAction(self::PRODUCT_APP), + ], 'chan_order_no' => $params['out_trade_no'], 'chan_trade_no' => $result->get('trade_no', ''), ]; } + private function doMini(array $params, array $order): array + { + $context = $this->collectOrderContext($order); + $buyerId = trim((string) ($context['buyer_id'] ?? '')); + if ($buyerId === '') { + throw new PaymentException('支付宝小程序支付缺少 buyer_id', 402); + } + + $miniParams = array_merge($params, [ + 'buyer_id' => $buyerId, + ]); + + /** @var Collection $result */ + $result = Pay::alipay()->mini($miniParams); + $tradeNo = (string) $this->extractCollectionValue($result, ['trade_no', 'order_id', 'out_trade_no'], ''); + + return [ + 'pay_product' => self::PRODUCT_MINI, + 'pay_action' => $this->productAction(self::PRODUCT_MINI), + 'pay_params' => [ + 'type' => 'mini', + 'trade_no' => $tradeNo, + 'buyer_id' => $buyerId, + 'pay_product' => self::PRODUCT_MINI, + 'pay_action' => $this->productAction(self::PRODUCT_MINI), + 'raw' => $result->all(), + ], + 'chan_order_no' => $params['out_trade_no'], + 'chan_trade_no' => $tradeNo, + ]; + } + + private function doPos(array $params, array $order): array + { + $context = $this->collectOrderContext($order); + $authCode = trim((string) ($context['auth_code'] ?? '')); + if ($authCode === '') { + throw new PaymentException('支付宝刷卡支付缺少 auth_code', 402); + } + + $posParams = array_merge($params, [ + 'auth_code' => $authCode, + ]); + + /** @var Collection $result */ + $result = Pay::alipay()->pos($posParams); + $tradeNo = (string) $this->extractCollectionValue($result, ['trade_no', 'order_id', 'out_trade_no'], ''); + + return [ + 'pay_product' => self::PRODUCT_POS, + 'pay_action' => $this->productAction(self::PRODUCT_POS), + 'pay_params' => [ + 'type' => 'pos', + 'trade_no' => $tradeNo, + 'auth_code' => $authCode, + 'pay_product' => self::PRODUCT_POS, + 'pay_action' => $this->productAction(self::PRODUCT_POS), + 'raw' => $result->all(), + ], + 'chan_order_no' => $params['out_trade_no'], + 'chan_trade_no' => $tradeNo, + ]; + } + + private function doTransfer(array $params, array $order): array + { + $context = $this->collectOrderContext($order); + $payeeInfo = $this->normalizeParamBag($context['payee_info'] ?? null); + if ($payeeInfo === []) { + throw new PaymentException('支付宝转账缺少 payee_info', 402); + } + + $transferParams = [ + 'out_biz_no' => $params['out_trade_no'], + 'trans_amount' => $params['total_amount'], + 'payee_info' => $payeeInfo, + ]; + + $notifyUrl = (string) ($params['_notify_url'] ?? ''); + if ($notifyUrl !== '') { + $transferParams['_notify_url'] = $notifyUrl; + } + + $orderTitle = trim((string) ($context['order_title'] ?? $context['subject'] ?? '')); + if ($orderTitle !== '') { + $transferParams['order_title'] = $orderTitle; + } + + $remark = trim((string) ($context['remark'] ?? $context['body'] ?? '')); + if ($remark !== '') { + $transferParams['remark'] = $remark; + } + + /** @var Collection $result */ + $result = Pay::alipay()->transfer($transferParams); + $tradeNo = (string) $this->extractCollectionValue($result, ['trade_no', 'order_id', 'out_biz_no'], ''); + + return [ + 'pay_product' => self::PRODUCT_TRANSFER, + 'pay_action' => $this->productAction(self::PRODUCT_TRANSFER), + 'pay_params' => [ + 'type' => 'transfer', + 'trade_no' => $tradeNo, + 'out_biz_no' => $transferParams['out_biz_no'], + 'trans_amount' => $transferParams['trans_amount'], + 'payee_info' => $payeeInfo, + 'pay_product' => self::PRODUCT_TRANSFER, + 'pay_action' => $this->productAction(self::PRODUCT_TRANSFER), + 'raw' => $result->all(), + ], + 'chan_order_no' => $params['out_trade_no'], + 'chan_trade_no' => $tradeNo, + ]; + } + public function query(array $order): array { - $outTradeNo = $order['chan_order_no'] ?? $order['order_id'] ?? ''; + $product = $this->chooseProduct($order, false); + $action = $this->productAction($product); + $outTradeNo = (string) ($order['chan_order_no'] ?? $order['order_id'] ?? $order['out_trade_no'] ?? ''); + $queryParams = $action === 'transfer' + ? ['out_biz_no' => $outTradeNo, '_action' => $action] + : ['out_trade_no' => $outTradeNo, '_action' => $action]; try { /** @var Collection $result */ - $result = Pay::alipay()->query(['out_trade_no' => $outTradeNo]); - $tradeStatus = $result->get('trade_status', ''); - $tradeNo = $result->get('trade_no', ''); - $totalAmount = (float)$result->get('total_amount', 0); - $status = in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true) ? 'success' : $tradeStatus; + $result = Pay::alipay()->query($queryParams); + $tradeStatus = (string) $result->get('trade_status', $result->get('status', '')); + $tradeNo = (string) $this->extractCollectionValue($result, ['trade_no', 'order_id', 'out_biz_no'], ''); + $totalAmount = (string) $this->extractCollectionValue($result, ['total_amount', 'trans_amount', 'amount'], '0'); + $status = match ($action) { + 'transfer' => in_array($tradeStatus, ['SUCCESS', 'PAY_SUCCESS', 'SUCCESSFUL'], true) ? 'success' : $tradeStatus, + default => in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true) ? 'success' : $tradeStatus, + }; return [ + 'pay_product' => $product, + 'pay_action' => $action, 'status' => $status, 'chan_trade_no' => $tradeNo, - 'pay_amount' => $totalAmount, + 'pay_amount' => (int) round(((float) $totalAmount) * 100), ]; } catch (\Throwable $e) { throw new PaymentException('支付宝查询失败:' . $e->getMessage(), 402); @@ -189,11 +571,21 @@ class AlipayPayment extends BasePayment implements PaymentInterface public function close(array $order): array { - $outTradeNo = $order['chan_order_no'] ?? $order['order_id'] ?? ''; + $product = $this->chooseProduct($order, false); + $action = $this->productAction($product); + if ($action === 'transfer') { + throw new PaymentException('支付宝转账不支持关单', 402); + } + + $outTradeNo = (string) ($order['chan_order_no'] ?? $order['order_id'] ?? $order['out_trade_no'] ?? ''); + $closeParams = [ + 'out_trade_no' => $outTradeNo, + '_action' => $action, + ]; try { - Pay::alipay()->close(['out_trade_no' => $outTradeNo]); - return ['success' => true, 'msg' => '关闭成功']; + Pay::alipay()->close($closeParams); + return ['success' => true, 'msg' => '关闭成功', 'pay_product' => $product, 'pay_action' => $action]; } catch (\Throwable $e) { throw new PaymentException('支付宝关单失败:' . $e->getMessage(), 402); } @@ -201,9 +593,11 @@ class AlipayPayment extends BasePayment implements PaymentInterface public function refund(array $order): array { - $outTradeNo = $order['chan_order_no'] ?? $order['order_id'] ?? ''; - $refundAmount = (float)($order['refund_amount'] ?? 0); - $refundNo = $order['refund_no'] ?? $order['order_id'] . '_' . time(); + $product = $this->chooseProduct($order, false); + $action = $this->productAction($product); + $outTradeNo = (string) ($order['chan_order_no'] ?? $order['order_id'] ?? $order['out_trade_no'] ?? ''); + $refundAmount = (int) ($order['refund_amount'] ?? 0); + $refundNo = (string) ($order['refund_no'] ?? (($order['order_id'] ?? 'refund') . '_' . time())); $refundReason = (string)($order['refund_reason'] ?? ''); if ($outTradeNo === '' || $refundAmount <= 0) { @@ -211,9 +605,10 @@ class AlipayPayment extends BasePayment implements PaymentInterface } $params = [ - 'out_trade_no' => $outTradeNo, - 'refund_amount' => sprintf('%.2f', $refundAmount), - 'out_request_no' => $refundNo, + $action === 'transfer' ? 'out_biz_no' : 'out_trade_no' => $outTradeNo, + 'refund_amount' => FormatHelper::amount($refundAmount), + 'out_request_no' => $refundNo, + '_action' => $action, ]; if ($refundReason !== '') { $params['refund_reason'] = $refundReason; @@ -227,9 +622,11 @@ class AlipayPayment extends BasePayment implements PaymentInterface if ($code === '10000' || $code === 10000) { return [ - 'success' => true, - 'chan_refund_no'=> $result->get('trade_no', $refundNo), - 'msg' => '退款成功', + 'success' => true, + 'pay_product' => $product, + 'pay_action' => $action, + 'chan_refund_no' => (string) $this->extractCollectionValue($result, ['trade_no', 'refund_no', 'out_request_no'], $refundNo), + 'msg' => '退款成功', ]; } throw new PaymentException($subMsg ?: '退款失败', 402); @@ -250,17 +647,21 @@ class AlipayPayment extends BasePayment implements PaymentInterface $tradeStatus = $result->get('trade_status', ''); $outTradeNo = $result->get('out_trade_no', ''); $tradeNo = $result->get('trade_no', ''); - $totalAmount = (float)$result->get('total_amount', 0); + $totalAmount = (string) $result->get('total_amount', '0'); + $paidAt = (string) $result->get('gmt_payment', ''); if (!in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true)) { throw new PaymentException('回调状态异常:' . $tradeStatus, 402); } return [ - 'status' => 'success', - 'pay_order_id' => $outTradeNo, - 'chan_trade_no'=> $tradeNo, - 'amount' => $totalAmount, + 'success' => true, + 'status' => 'success', + 'pay_order_id' => $outTradeNo, + 'chan_order_no' => $outTradeNo, + 'chan_trade_no' => $tradeNo, + 'amount' => (int) round(((float) $totalAmount) * 100), + 'paid_at' => $paidAt !== '' ? (FormatHelper::timestamp((int) strtotime($paidAt)) ?: null) : null, ]; } catch (PaymentException $e) { throw $e; @@ -268,6 +669,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface throw new PaymentException('支付宝回调验签失败:' . $e->getMessage(), 402); } } + public function notifySuccess(): string|Response { return 'success'; diff --git a/app/common/payment/LakalaPayment.php b/app/common/payment/LakalaPayment.php deleted file mode 100644 index ddcdb3b..0000000 --- a/app/common/payment/LakalaPayment.php +++ /dev/null @@ -1,87 +0,0 @@ - 'lakala', - 'name' => '拉卡拉(示例)', - 'author' => '', - 'link' => '', - 'pay_types' => ['alipay', 'wechat'], - 'transfer_types' => [], - 'config_schema' => [ - 'fields' => [ - ['field' => 'notify_url', 'label' => '异步通知地址', 'type' => 'text', 'required' => false], - ], - ], - ]; - - public function pay(array $order): array - { - $orderId = (string)($order['order_id'] ?? ''); - $amount = (string)($order['amount'] ?? '0.00'); - $extra = is_array($order['extra'] ?? null) ? $order['extra'] : []; - - if ($orderId === '') { - throw new PaymentException('缺少订单号', 402); - } - - // 这里先返回“可联调”的 pay_params:默认给一个 qrcode 字符串 - // 真实实现中应调用拉卡拉下单接口,返回二维码链接/支付链接/预支付信息等。 - $qrcode = $extra['mock_qrcode'] ?? ('LAKALA_MOCK_QRCODE:' . $orderId . ':' . $amount); - - return [ - 'pay_params' => [ - 'type' => 'qrcode', - 'qrcode_url' => $qrcode, - 'qrcode_data'=> $qrcode, - ], - 'chan_order_no' => $orderId, - 'chan_trade_no' => '', - ]; - } - - public function query(array $order): array - { - throw new PaymentException('LakalaPayment::query 暂未实现', 402); - } - - public function close(array $order): array - { - throw new PaymentException('LakalaPayment::close 暂未实现', 402); - } - - public function refund(array $order): array - { - throw new PaymentException('LakalaPayment::refund 暂未实现', 402); - } - - public function notify(Request $request): array - { - throw new PaymentException('LakalaPayment::notify 暂未实现', 402); - } - public function notifySuccess(): string|Response - { - return 'success'; - } - - public function notifyFail(): string|Response - { - return 'fail'; - } -} diff --git a/app/common/util/FormatHelper.php b/app/common/util/FormatHelper.php new file mode 100644 index 0000000..fafd3fa --- /dev/null +++ b/app/common/util/FormatHelper.php @@ -0,0 +1,188 @@ + 0 ? self::amount($amount) : '不限'; + } + + /** + * 次数格式化,0 时显示不限。 + */ + public static function countOrUnlimited(int $count): string + { + return $count > 0 ? (string) $count : '不限'; + } + + /** + * 费率格式化,单位为百分点。 + */ + public static function rate(int $basisPoints): string + { + return number_format($basisPoints / 100, 2, '.', '') . '%'; + } + + /** + * 延迟格式化。 + */ + public static function latency(int $latencyMs): string + { + return $latencyMs > 0 ? $latencyMs . ' ms' : '0 ms'; + } + + /** + * 日期格式化。 + */ + public static function date(mixed $value, string $emptyText = ''): string + { + return self::formatTemporalValue($value, 'Y-m-d', $emptyText); + } + + /** + * 日期时间格式化。 + */ + public static function dateTime(mixed $value, string $emptyText = ''): string + { + return self::formatTemporalValue($value, 'Y-m-d H:i:s', $emptyText); + } + + /** + * 按时间戳格式化。 + */ + public static function timestamp(int $timestamp, string $pattern = 'Y-m-d H:i:s', string $emptyText = ''): string + { + if ($timestamp <= 0) { + return $emptyText; + } + + return date($pattern, $timestamp); + } + + /** + * JSON 文本格式化。 + */ + public static function json(mixed $value, string $emptyText = ''): string + { + if ($value === null || $value === '' || $value === []) { + return $emptyText; + } + + if (is_string($value)) { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + $encoded = json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + return $encoded !== false ? $encoded : $emptyText; + } + + return $value; + } + + $encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + return $encoded !== false ? $encoded : $emptyText; + } + + /** + * 映射表文本转换。 + */ + public static function textFromMap(int $value, array $map, string $default = '未知'): string + { + return (string) ($map[$value] ?? $default); + } + + /** + * 接口凭证明文脱敏。 + */ + public static function maskCredentialValue(string $credentialValue, bool $maskShortValue = true): string + { + $credentialValue = trim($credentialValue); + if ($credentialValue === '') { + return ''; + } + + $length = strlen($credentialValue); + if ($length <= 8) { + return $maskShortValue ? str_repeat('*', $length) : $credentialValue; + } + + return substr($credentialValue, 0, 4) . '****' . substr($credentialValue, -4); + } + + /** + * 将模型或对象归一化成数组。 + */ + public static function normalizeModel(mixed $value): ?array + { + if ($value === null) { + return null; + } + + if (is_array($value)) { + return $value; + } + + if (is_object($value)) { + if (method_exists($value, 'toArray')) { + $data = $value->toArray(); + return is_array($data) ? $data : null; + } + + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + return null; + } + + $data = json_decode($json, true); + return is_array($data) ? $data : null; + } + + return null; + } + + /** + * 统一格式化时间值。 + */ + private static function formatTemporalValue(mixed $value, string $pattern, string $emptyText): string + { + if ($value === null || $value === '') { + return $emptyText; + } + + if (is_string($value)) { + $text = trim($value); + return $text === '' ? $emptyText : $text; + } + + if ($value instanceof DateTimeInterface) { + return $value->format($pattern); + } + + if (is_object($value) && method_exists($value, 'format')) { + return $value->format($pattern); + } + + return (string) $value; + } +} diff --git a/app/common/util/JwtTokenManager.php b/app/common/util/JwtTokenManager.php new file mode 100644 index 0000000..d38fe1e --- /dev/null +++ b/app/common/util/JwtTokenManager.php @@ -0,0 +1,254 @@ +guardConfig($guard); + $this->assertHmacSecretLength($guard, (string) $guardConfig['secret']); + $ttlSeconds = max(60, $ttlSeconds ?? (int) $guardConfig['ttl']); + + $now = time(); + $jti = bin2hex(random_bytes(16)); + $payload = array_merge([ + 'iss' => (string) config('auth.issuer', 'mpay'), + 'iat' => $now, + 'nbf' => $now, + 'exp' => $now + $ttlSeconds, + 'jti' => $jti, + 'guard' => $guard, + ], $claims); + + $token = JWT::encode($payload, (string) $guardConfig['secret'], 'HS256'); + + $session = array_merge($sessionData, [ + 'guard' => $guard, + 'jti' => $jti, + 'issued_at' => FormatHelper::timestamp($now), + 'expires_at' => FormatHelper::timestamp($now + $ttlSeconds), + ]); + + $this->storeSession($guard, $jti, $session, $ttlSeconds); + + return [ + 'token' => $token, + 'expires_in' => $ttlSeconds, + 'jti' => $jti, + 'claims' => $payload, + 'session' => $session, + ]; + } + + /** + * 验证 JWT,并恢复对应的 Redis 会话数据。 + * + * 说明: + * - 先校验签名和过期时间。 + * - 再通过 jti 反查 Redis 会话,确保 token 仍然有效。 + * - 每次命中会刷新最近访问时间。 + * + * @return array{claims:array,session:array}|null + */ + public function verify(string $guard, string $token, string $ip = '', string $userAgent = ''): ?array + { + $payload = $this->decode($guard, $token); + if ($payload === null) { + return null; + } + + $jti = (string) ($payload['jti'] ?? ''); + if ($jti === '') { + return null; + } + + $session = $this->session($guard, $jti); + if ($session === null) { + return null; + } + + $now = time(); + $expiresAt = (int) ($payload['exp'] ?? 0); + $ttl = max(1, $expiresAt - $now); + + $session['last_used_at'] = FormatHelper::timestamp($now); + if ($ip !== '') { + $session['last_used_ip'] = $ip; + } + if ($userAgent !== '') { + $session['user_agent'] = $userAgent; + } + + $this->storeSession($guard, $jti, $session, $ttl); + + return [ + 'claims' => $payload, + 'session' => $session, + ]; + } + + /** + * 通过 token 撤销登录态。 + * + * 适用于主动退出登录场景。 + */ + public function revoke(string $guard, string $token): bool + { + $payload = $this->decode($guard, $token); + if ($payload === null) { + return false; + } + + $jti = (string) ($payload['jti'] ?? ''); + if ($jti === '') { + return false; + } + + return (bool) Redis::connection()->del($this->sessionKey($guard, $jti)); + } + + /** + * 通过 jti 直接撤销登录态。 + * + * 适用于已经掌握会话标识但没有原始 token 的补偿清理场景。 + */ + public function revokeByJti(string $guard, string $jti): bool + { + if ($jti === '') { + return false; + } + + return (bool) Redis::connection()->del($this->sessionKey($guard, $jti)); + } + + /** + * 根据 jti 获取会话数据。 + * + * 返回值来自 Redis,若已过期或不存在则返回 null。 + */ + public function session(string $guard, string $jti): ?array + { + $raw = Redis::connection()->get($this->sessionKey($guard, $jti)); + if (!is_string($raw) || $raw === '') { + return null; + } + + $session = json_decode($raw, true); + return is_array($session) ? $session : null; + } + + /** + * 解码并校验 JWT。 + * + * 只做签名、过期和 guard 校验,不处理 Redis 会话。 + */ + protected function decode(string $guard, string $token): ?array + { + $guardConfig = $this->guardConfig($guard); + $this->assertHmacSecretLength($guard, (string) $guardConfig['secret']); + + try { + JWT::$leeway = (int) config('auth.leeway', 30); + $payload = JWT::decode($token, new Key((string) $guardConfig['secret'], 'HS256')); + } catch (ExpiredException|SignatureInvalidException|Throwable) { + return null; + } + + $data = json_decode(json_encode($payload, JSON_UNESCAPED_UNICODE), true); + if (!is_array($data)) { + return null; + } + + if (($data['guard'] ?? '') !== $guard) { + return null; + } + + return $data; + } + + /** + * 将会话数据写入 Redis,并设置 TTL。 + */ + protected function storeSession(string $guard, string $jti, array $session, int $ttlSeconds): void + { + Redis::connection()->setEx( + $this->sessionKey($guard, $jti), + max(60, $ttlSeconds), + json_encode($session, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + } + + /** + * 构造 Redis 会话键。 + * + * 最终格式由 guard 对应的 redis_prefix 加上 jti 组成。 + */ + protected function sessionKey(string $guard, string $jti): string + { + return $this->guardConfig($guard)['redis_prefix'] . $jti; + } + + /** + * 获取指定认证域的配置。 + * + * @throws \InvalidArgumentException 当 guard 未配置时抛出 + */ + protected function guardConfig(string $guard): array + { + $guards = (array) config('auth.guards', []); + if (!isset($guards[$guard])) { + throw new \InvalidArgumentException("Unknown auth guard: {$guard}"); + } + + return $guards[$guard]; + } + + /** + * 校验 HS256 密钥长度,避免 firebase/php-jwt 抛出底层异常。 + */ + protected function assertHmacSecretLength(string $guard, string $secret): void + { + if (strlen($secret) >= 32) { + return; + } + + $envNames = match ($guard) { + 'admin' => 'AUTH_ADMIN_JWT_SECRET or AUTH_JWT_SECRET', + 'merchant' => 'AUTH_MERCHANT_JWT_SECRET or AUTH_JWT_SECRET', + default => 'the configured JWT secret', + }; + + throw new \RuntimeException(sprintf( + 'JWT secret for guard "%s" is too short for HS256. Please set %s to at least 32 ASCII characters.', + $guard, + $envNames + )); + } +} diff --git a/app/common/utils/EpayUtil.php b/app/common/utils/EpayUtil.php deleted file mode 100644 index d9dbee4..0000000 --- a/app/common/utils/EpayUtil.php +++ /dev/null @@ -1,65 +0,0 @@ - $params 请求参数 - */ - public static function make(array $params, string $secret): string - { - unset($params['sign'], $params['sign_type']); - - $filtered = []; - foreach ($params as $k => $v) { - if ($v === null) { - continue; - } - if (is_string($v) && trim($v) === '') { - continue; - } - $filtered[$k] = is_bool($v) ? ($v ? '1' : '0') : (string)$v; - } - - ksort($filtered); - - $pairs = []; - foreach ($filtered as $k => $v) { - $pairs[] = $k . '=' . $v; - } - - $pairs[] = 'key=' . $secret; - - return strtolower(md5(implode('&', $pairs))); - } - - /** - * 校验签名 - * - * @param array $params - */ - public static function verify(array $params, string $secret): bool - { - $sign = strtolower((string)($params['sign'] ?? '')); - if ($sign === '') { - return false; - } - - return hash_equals(self::make($params, $secret), $sign); - } -} - diff --git a/app/common/utils/JwtUtil.php b/app/common/utils/JwtUtil.php deleted file mode 100644 index 443321d..0000000 --- a/app/common/utils/JwtUtil.php +++ /dev/null @@ -1,61 +0,0 @@ - $now, - 'exp' => $now + $ttl, - ]); - - return JWT::encode($payload, $secret, $alg); - } - - /** - * 解析 JWT - */ - public static function parseToken(string $token): array - { - $config = config('jwt', []); - $secret = $config['secret'] ?? 'mpay-secret'; - $alg = $config['alg'] ?? 'HS256'; - - $decoded = JWT::decode($token, new Key($secret, $alg)); - return json_decode(json_encode($decoded, JSON_UNESCAPED_UNICODE), true) ?: []; - } - - /** - * 获取 ttl(秒) - */ - public static function getTtl(): int - { - $config = config('jwt', []); - return (int)($config['ttl'] ?? 7200); - } - - /** - * 获取缓存前缀 - */ - public static function getCachePrefix(): string - { - $config = config('jwt', []); - return $config['cache_prefix'] ?? 'token_'; - } -} - - diff --git a/app/events/SystemConfig.php b/app/events/SystemConfig.php deleted file mode 100644 index d83ceff..0000000 --- a/app/events/SystemConfig.php +++ /dev/null @@ -1,28 +0,0 @@ -reloadCache(); - } -} - - diff --git a/app/exception/BalanceInsufficientException.php b/app/exception/BalanceInsufficientException.php new file mode 100644 index 0000000..bd69f7e --- /dev/null +++ b/app/exception/BalanceInsufficientException.php @@ -0,0 +1,33 @@ + $merchantId ?: null, + 'need_amount' => $needAmount ?: null, + 'available_amount' => $availableAmount ?: null, + ], static fn ($value) => $value !== null && $value !== ''); + + if (!empty($data)) { + $payload = array_merge($payload, $data); + } + + if (!empty($payload)) { + $this->data($payload); + } + } +} diff --git a/app/exception/BusinessStateException.php b/app/exception/BusinessStateException.php new file mode 100644 index 0000000..da7b0a7 --- /dev/null +++ b/app/exception/BusinessStateException.php @@ -0,0 +1,25 @@ +data($data); + } + } +} diff --git a/app/exception/ConflictException.php b/app/exception/ConflictException.php new file mode 100644 index 0000000..7a74372 --- /dev/null +++ b/app/exception/ConflictException.php @@ -0,0 +1,25 @@ +data($data); + } + } +} diff --git a/app/exception/NotifyRetryExceededException.php b/app/exception/NotifyRetryExceededException.php new file mode 100644 index 0000000..c480ad8 --- /dev/null +++ b/app/exception/NotifyRetryExceededException.php @@ -0,0 +1,31 @@ + $notifyNo, + ], static fn ($value) => $value !== '' && $value !== null); + + if (!empty($data)) { + $payload = array_merge($payload, $data); + } + + if (!empty($payload)) { + $this->data($payload); + } + } +} diff --git a/app/exception/PaymentException.php b/app/exception/PaymentException.php new file mode 100644 index 0000000..724d41b --- /dev/null +++ b/app/exception/PaymentException.php @@ -0,0 +1,25 @@ +data($data); + } + } +} diff --git a/app/exception/ResourceNotFoundException.php b/app/exception/ResourceNotFoundException.php new file mode 100644 index 0000000..ec226fb --- /dev/null +++ b/app/exception/ResourceNotFoundException.php @@ -0,0 +1,25 @@ +data($data); + } + } +} diff --git a/app/exception/ValidationException.php b/app/exception/ValidationException.php new file mode 100644 index 0000000..53de349 --- /dev/null +++ b/app/exception/ValidationException.php @@ -0,0 +1,30 @@ +data($data); + } + } +} diff --git a/app/exceptions/BadRequestException.php b/app/exceptions/BadRequestException.php deleted file mode 100644 index 8b0989b..0000000 --- a/app/exceptions/BadRequestException.php +++ /dev/null @@ -1,25 +0,0 @@ -data($data); - } - } -} - diff --git a/app/exceptions/ForbiddenException.php b/app/exceptions/ForbiddenException.php deleted file mode 100644 index 598313d..0000000 --- a/app/exceptions/ForbiddenException.php +++ /dev/null @@ -1,25 +0,0 @@ -data($data); - } - } -} - diff --git a/app/exceptions/InternalServerException.php b/app/exceptions/InternalServerException.php deleted file mode 100644 index 3872068..0000000 --- a/app/exceptions/InternalServerException.php +++ /dev/null @@ -1,26 +0,0 @@ -data($data); - } - } -} - diff --git a/app/exceptions/NotFoundException.php b/app/exceptions/NotFoundException.php deleted file mode 100644 index f51c636..0000000 --- a/app/exceptions/NotFoundException.php +++ /dev/null @@ -1,25 +0,0 @@ -data($data); - } - } -} - diff --git a/app/exceptions/PaymentException.php b/app/exceptions/PaymentException.php deleted file mode 100644 index 12ecacd..0000000 --- a/app/exceptions/PaymentException.php +++ /dev/null @@ -1,25 +0,0 @@ - 'lakala']); - */ -class PaymentException extends BusinessException -{ - public function __construct(string $message = '支付业务异常', int $bizCode = 402, array $data = []) - { - parent::__construct($message, $bizCode); - if (!empty($data)) { - $this->data($data); - } - } -} diff --git a/app/exceptions/UnauthorizedException.php b/app/exceptions/UnauthorizedException.php deleted file mode 100644 index b83cd34..0000000 --- a/app/exceptions/UnauthorizedException.php +++ /dev/null @@ -1,26 +0,0 @@ -data($data); - } - } -} - diff --git a/app/exceptions/ValidationException.php b/app/exceptions/ValidationException.php deleted file mode 100644 index 9c9b8a1..0000000 --- a/app/exceptions/ValidationException.php +++ /dev/null @@ -1,25 +0,0 @@ -data($data); - } - } -} diff --git a/app/http/admin/controller/AdminController.php b/app/http/admin/controller/AdminController.php deleted file mode 100644 index 4faeecd..0000000 --- a/app/http/admin/controller/AdminController.php +++ /dev/null @@ -1,34 +0,0 @@ -currentUserId($request); - if ($adminId <= 0) { - return $this->fail('未获取到用户信息,请先登录', 401); - } - - $data = $this->adminService->getInfoById($adminId); - return $this->success($data); - } -} diff --git a/app/http/admin/controller/AuthController.php b/app/http/admin/controller/AuthController.php deleted file mode 100644 index acd5fe9..0000000 --- a/app/http/admin/controller/AuthController.php +++ /dev/null @@ -1,55 +0,0 @@ -captchaService->generate(); - return $this->success($data); - } - - /** - * POST /login - * - * 用户登录 - */ - public function login(Request $request) - { - $username = $request->post('username', ''); - $password = $request->post('password', ''); - $verifyCode = $request->post('verifyCode', ''); - $captchaId = $request->post('captchaId', ''); - - // 参数校验 - if (empty($username) || empty($password) || empty($verifyCode) || empty($captchaId)) { - return $this->fail('请填写完整登录信息', 400); - } - - $data = $this->authService->login($username, $password, $verifyCode, $captchaId); - return $this->success($data); - } -} - diff --git a/app/http/admin/controller/ChannelController.php b/app/http/admin/controller/ChannelController.php deleted file mode 100644 index a49bab1..0000000 --- a/app/http/admin/controller/ChannelController.php +++ /dev/null @@ -1,651 +0,0 @@ -get('page', 1)); - $pageSize = max(1, (int)$request->get('page_size', 10)); - $filters = $this->resolveChannelFilters($request, true); - return $this->page($this->channelRepository->searchPaginate($filters, $page, $pageSize)); - } - - public function detail(Request $request) - { - $id = (int)$request->get('id', 0); - if ($id <= 0) { - return $this->fail('通道ID不能为空', 400); - } - - $channel = $this->channelRepository->find($id); - if (!$channel) { - return $this->fail('通道不存在', 404); - } - - $methodCode = ''; - if ((int)$channel->method_id > 0) { - $method = $this->methodRepository->find((int)$channel->method_id); - $methodCode = $method ? (string)$method->method_code : ''; - } - - try { - $configSchema = $this->pluginService->getConfigSchema((string)$channel->plugin_code, $methodCode); - $currentConfig = $channel->getConfigArray(); - if (isset($configSchema['fields']) && is_array($configSchema['fields'])) { - foreach ($configSchema['fields'] as &$field) { - $fieldName = $field['field'] ?? ''; - if ($fieldName !== '' && array_key_exists($fieldName, $currentConfig)) { - $field['value'] = $currentConfig[$fieldName]; - } - } - unset($field); - } - } catch (\Throwable $e) { - $configSchema = ['fields' => []]; - } - - return $this->success([ - 'channel' => $channel, - 'method_code' => $methodCode, - 'config_schema' => $configSchema, - ]); - } - - public function save(Request $request) - { - $data = $request->post(); - $id = (int)($data['id'] ?? 0); - $merchantId = (int)($data['merchant_id'] ?? 0); - $merchantAppId = (int)($data['merchant_app_id'] ?? ($data['app_id'] ?? 0)); - $channelCode = trim((string)($data['channel_code'] ?? ($data['chan_code'] ?? ''))); - $channelName = trim((string)($data['channel_name'] ?? ($data['chan_name'] ?? ''))); - $pluginCode = trim((string)($data['plugin_code'] ?? '')); - $methodCode = trim((string)($data['method_code'] ?? '')); - $enabledProducts = $data['enabled_products'] ?? []; - - if ($merchantId <= 0) { - return $this->fail('请选择所属商户', 400); - } - if ($merchantAppId <= 0) { - return $this->fail('请选择所属应用', 400); - } - if ($channelName === '') { - return $this->fail('请输入通道名称', 400); - } - if ($pluginCode === '' || $methodCode === '') { - return $this->fail('支付插件和支付方式不能为空', 400); - } - - $method = $this->methodRepository->findAnyByCode($methodCode); - if (!$method) { - return $this->fail('支付方式不存在', 400); - } - - if ($channelCode !== '') { - $exists = $this->channelRepository->findByChanCode($channelCode); - if ($exists && (int)$exists->id !== $id) { - return $this->fail('通道编码已存在', 400); - } - } - - try { - $configJson = $this->pluginService->buildConfigFromForm($pluginCode, $methodCode, $data); - } catch (\Throwable $e) { - return $this->fail('插件不存在或配置错误:' . $e->getMessage(), 400); - } - - $channelData = [ - 'mer_id' => $merchantId, - 'app_id' => $merchantAppId, - 'chan_code' => $channelCode !== '' ? $channelCode : 'CH' . date('YmdHis') . mt_rand(1000, 9999), - 'chan_name' => $channelName, - 'plugin_code' => $pluginCode, - 'pay_type_id' => (int)$method->id, - 'config' => array_merge($configJson, [ - 'enabled_products' => is_array($enabledProducts) ? array_values($enabledProducts) : [], - ]), - 'split_ratio' => isset($data['split_ratio']) ? (float)$data['split_ratio'] : 100, - 'chan_cost' => isset($data['channel_cost']) ? (float)$data['channel_cost'] : 0, - 'chan_mode' => in_array(strtolower(trim((string)($data['channel_mode'] ?? 'wallet'))), ['1', 'direct', 'merchant'], true) ? 1 : 0, - 'daily_limit' => isset($data['daily_limit']) ? (float)$data['daily_limit'] : 0, - 'daily_cnt' => isset($data['daily_count']) ? (int)$data['daily_count'] : 0, - 'min_amount' => isset($data['min_amount']) && $data['min_amount'] !== '' ? (float)$data['min_amount'] : null, - 'max_amount' => isset($data['max_amount']) && $data['max_amount'] !== '' ? (float)$data['max_amount'] : null, - 'status' => (int)($data['status'] ?? 1), - 'sort' => (int)($data['sort'] ?? 0), - ]; - - if ($id > 0) { - $channel = $this->channelRepository->find($id); - if (!$channel) { - return $this->fail('通道不存在', 404); - } - $this->channelRepository->updateById($id, $channelData); - } else { - $channel = $this->channelRepository->create($channelData); - $id = (int)$channel->id; - } - - return $this->success(['id' => $id], '保存成功'); - } - - public function toggle(Request $request) - { - $id = (int)$request->post('id', 0); - $status = $request->post('status', null); - if ($id <= 0 || $status === null) { - return $this->fail('参数错误', 400); - } - $ok = $this->channelRepository->updateById($id, ['status' => (int)$status]); - return $ok ? $this->success(null, '操作成功') : $this->fail('操作失败', 500); - } - - public function monitor(Request $request) - { - $filters = $this->resolveChannelFilters($request); - $days = $this->resolveDays($request->get('days', 7)); - $channels = $this->channelRepository->searchList($filters); - if ($channels->isEmpty()) { - return $this->success(['list' => [], 'summary' => $this->buildMonitorSummary([])]); - } - - $orderFilters = [ - 'merchant_id' => $filters['merchant_id'] ?? null, - 'merchant_app_id' => $filters['merchant_app_id'] ?? null, - 'method_id' => $filters['method_id'] ?? null, - 'created_from' => $days['created_from'], - 'created_to' => $days['created_to'], - ]; - $channelIds = []; - foreach ($channels as $channel) { - $channelIds[] = (int)$channel->id; - } - $statsMap = $this->orderRepository->aggregateByChannel($channelIds, $orderFilters); - $rows = []; - foreach ($channels as $channel) { - $rows[] = $this->buildMonitorRow($channel->toArray(), $statsMap[(int)$channel->id] ?? []); - } - - usort($rows, function (array $left, array $right) { - if (($right['health_score'] ?? 0) === ($left['health_score'] ?? 0)) { - return ($left['sort'] ?? 0) <=> ($right['sort'] ?? 0); - } - return ($right['health_score'] ?? 0) <=> ($left['health_score'] ?? 0); - }); - - return $this->success(['list' => $rows, 'summary' => $this->buildMonitorSummary($rows)]); - } - - public function polling(Request $request) - { - $filters = $this->resolveChannelFilters($request); - $days = $this->resolveDays($request->get('days', 7)); - $channels = $this->channelRepository->searchList($filters); - $testAmount = $request->get('test_amount', null); - $testAmount = ($testAmount === null || $testAmount === '') ? null : (float)$testAmount; - if ($channels->isEmpty()) { - return $this->success(['list' => [], 'summary' => $this->buildPollingSummary([])]); - } - - $orderFilters = [ - 'merchant_id' => $filters['merchant_id'] ?? null, - 'merchant_app_id' => $filters['merchant_app_id'] ?? null, - 'method_id' => $filters['method_id'] ?? null, - 'created_from' => $days['created_from'], - 'created_to' => $days['created_to'], - ]; - $channelIds = []; - foreach ($channels as $channel) { - $channelIds[] = (int)$channel->id; - } - $statsMap = $this->orderRepository->aggregateByChannel($channelIds, $orderFilters); - $rows = []; - foreach ($channels as $channel) { - $monitorRow = $this->buildMonitorRow($channel->toArray(), $statsMap[(int)$channel->id] ?? []); - $rows[] = $this->buildPollingRow($monitorRow, $testAmount); - } - - $stateWeight = ['ready' => 0, 'degraded' => 1, 'blocked' => 2]; - usort($rows, function (array $left, array $right) use ($stateWeight) { - $leftWeight = $stateWeight[$left['route_state'] ?? 'blocked'] ?? 9; - $rightWeight = $stateWeight[$right['route_state'] ?? 'blocked'] ?? 9; - if ($leftWeight === $rightWeight) { - if (($right['route_score'] ?? 0) === ($left['route_score'] ?? 0)) { - return ($left['sort'] ?? 0) <=> ($right['sort'] ?? 0); - } - return ($right['route_score'] ?? 0) <=> ($left['route_score'] ?? 0); - } - return $leftWeight <=> $rightWeight; - }); - - foreach ($rows as $index => &$row) { - $row['route_rank'] = $index + 1; - } - unset($row); - - return $this->success(['list' => $rows, 'summary' => $this->buildPollingSummary($rows)]); - } - - public function policyList(Request $request) - { - $merchantId = (int)$request->get('merchant_id', 0); - $merchantAppId = (int)$request->get('merchant_app_id', $request->get('app_id', 0)); - $methodCode = trim((string)$request->get('method_code', '')); - $pluginCode = trim((string)$request->get('plugin_code', '')); - $status = $request->get('status', null); - - $policies = $this->routePolicyService->list(); - $channelMap = []; - foreach ($this->channelRepository->searchList([]) as $channel) { - $channelMap[(int)$channel->id] = $channel->toArray(); - } - - $filtered = array_values(array_filter($policies, function (array $policy) use ($merchantId, $merchantAppId, $methodCode, $pluginCode, $status) { - if ($merchantId > 0 && (int)($policy['merchant_id'] ?? 0) !== $merchantId) return false; - if ($merchantAppId > 0 && (int)($policy['merchant_app_id'] ?? 0) !== $merchantAppId) return false; - if ($methodCode !== '' && (string)($policy['method_code'] ?? '') !== $methodCode) return false; - if ($pluginCode !== '' && (string)($policy['plugin_code'] ?? '') !== $pluginCode) return false; - if ($status !== null && $status !== '' && (int)($policy['status'] ?? 0) !== (int)$status) return false; - return true; - })); - - $list = []; - foreach ($filtered as $policy) { - $items = []; - foreach (($policy['items'] ?? []) as $index => $item) { - $channelId = (int)($item['channel_id'] ?? 0); - $channel = $channelMap[$channelId] ?? []; - $items[] = [ - 'channel_id' => $channelId, - 'role' => trim((string)($item['role'] ?? ($index === 0 ? 'primary' : 'backup'))), - 'weight' => max(0, (int)($item['weight'] ?? 100)), - 'priority' => max(1, (int)($item['priority'] ?? ($index + 1))), - 'chan_code' => (string)($channel['chan_code'] ?? ''), - 'chan_name' => (string)($channel['chan_name'] ?? ''), - 'channel_status' => isset($channel['status']) ? (int)$channel['status'] : null, - 'sort' => (int)($channel['sort'] ?? 0), - 'plugin_code' => (string)($channel['plugin_code'] ?? ''), - 'method_id' => (int)($channel['method_id'] ?? 0), - 'merchant_id' => (int)($channel['merchant_id'] ?? 0), - 'merchant_app_id' => (int)($channel['merchant_app_id'] ?? 0), - ]; - } - usort($items, fn(array $left, array $right) => ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0)); - $policy['items'] = $items; - $policy['channel_count'] = count($items); - $list[] = $policy; - } - - return $this->success([ - 'list' => $list, - 'summary' => [ - 'total' => count($list), - 'enabled' => count(array_filter($list, fn(array $policy) => (int)($policy['status'] ?? 0) === 1)), - ], - ]); - } - - public function policySave(Request $request) - { - try { - $payload = $this->preparePolicyPayload($request->post(), true); - return $this->success($this->routePolicyService->save($payload), '保存成功'); - } catch (\InvalidArgumentException $e) { - return $this->fail($e->getMessage(), 400); - } - } - - public function policyPreview(Request $request) - { - try { - $payload = $this->preparePolicyPayload($request->post(), false); - $testAmount = $request->post('test_amount', $request->post('preview_amount', 0)); - $amount = ($testAmount === null || $testAmount === '') ? 0 : (float)$testAmount; - $preview = $this->channelRouterService->previewPolicyDraft( - (int)$payload['merchant_id'], - (int)$payload['merchant_app_id'], - (int)$payload['method_id'], - $payload, - $amount - ); - return $this->success($preview); - } catch (\Throwable $e) { - return $this->fail($e->getMessage(), 400); - } - } - - public function policyDelete(Request $request) - { - $id = trim((string)$request->post('id', '')); - if ($id === '') { - return $this->fail('策略ID不能为空', 400); - } - $ok = $this->routePolicyService->delete($id); - return $ok ? $this->success(null, '删除成功') : $this->fail('策略不存在或已删除', 404); - } - - private function preparePolicyPayload(array $data, bool $requirePolicyName = true): array - { - $policyName = trim((string)($data['policy_name'] ?? '')); - $merchantId = (int)($data['merchant_id'] ?? 0); - $merchantAppId = (int)($data['merchant_app_id'] ?? ($data['app_id'] ?? 0)); - $methodCode = trim((string)($data['method_code'] ?? '')); - $pluginCode = trim((string)($data['plugin_code'] ?? '')); - $routeMode = trim((string)($data['route_mode'] ?? 'priority')); - $status = (int)($data['status'] ?? 1); - $itemsInput = $data['items'] ?? []; - - if ($requirePolicyName && $policyName === '') throw new \InvalidArgumentException('请输入策略名称'); - if ($methodCode === '') throw new \InvalidArgumentException('请选择支付方式'); - if (!in_array($routeMode, ['priority', 'weight', 'failover'], true)) throw new \InvalidArgumentException('路由模式不合法'); - if (!is_array($itemsInput) || $itemsInput === []) throw new \InvalidArgumentException('请至少选择一个通道'); - if ($merchantId <= 0 || $merchantAppId <= 0) throw new \InvalidArgumentException('请先选择商户和应用'); - - $method = $this->methodRepository->findAnyByCode($methodCode); - if (!$method) throw new \InvalidArgumentException('支付方式不存在'); - - $channelMap = []; - foreach ($this->channelRepository->searchList([]) as $channel) { - $channelMap[(int)$channel->id] = $channel->toArray(); - } - - $normalizedItems = []; - $usedChannelIds = []; - foreach ($itemsInput as $index => $item) { - $channelId = (int)($item['channel_id'] ?? 0); - if ($channelId <= 0) throw new \InvalidArgumentException('策略项中的通道ID不合法'); - if (in_array($channelId, $usedChannelIds, true)) throw new \InvalidArgumentException('策略中存在重复通道,请去重后再提交'); - - $channel = $channelMap[$channelId] ?? null; - if (!$channel) throw new \InvalidArgumentException('存在未找到的通道,请刷新后重试'); - if ($merchantId > 0 && (int)$channel['merchant_id'] !== $merchantId) throw new \InvalidArgumentException('策略中的通道与商户不匹配'); - if ($merchantAppId > 0 && (int)$channel['merchant_app_id'] !== $merchantAppId) throw new \InvalidArgumentException('策略中的通道与应用不匹配'); - if ((int)$channel['method_id'] !== (int)$method->id) throw new \InvalidArgumentException('策略中的通道与支付方式不匹配'); - if ($pluginCode !== '' && (string)$channel['plugin_code'] !== $pluginCode) throw new \InvalidArgumentException('策略中的通道与插件不匹配'); - - $defaultRole = $routeMode === 'weight' ? 'normal' : ($index === 0 ? 'primary' : 'backup'); - $role = trim((string)($item['role'] ?? $defaultRole)); - if (!in_array($role, ['primary', 'backup', 'normal'], true)) { - $role = $defaultRole; - } - $normalizedItems[] = [ - 'channel_id' => $channelId, - 'role' => $role, - 'weight' => max(0, (int)($item['weight'] ?? 100)), - 'priority' => max(1, (int)($item['priority'] ?? ($index + 1))), - ]; - $usedChannelIds[] = $channelId; - } - - usort($normalizedItems, function (array $left, array $right) { - if (($left['priority'] ?? 0) === ($right['priority'] ?? 0)) { - return ($right['weight'] ?? 0) <=> ($left['weight'] ?? 0); - } - return ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0); - }); - - foreach ($normalizedItems as $index => &$item) { - $item['priority'] = $index + 1; - if ($routeMode === 'weight' && $item['role'] === 'backup') { - $item['role'] = 'normal'; - } - } - unset($item); - - return [ - 'id' => trim((string)($data['id'] ?? '')), - 'policy_name' => $policyName !== '' ? $policyName : '策略草稿预览', - 'merchant_id' => $merchantId, - 'merchant_app_id' => $merchantAppId, - 'method_code' => $methodCode, - 'method_id' => (int)$method->id, - 'plugin_code' => $pluginCode, - 'route_mode' => $routeMode, - 'status' => $status, - 'circuit_breaker_threshold' => max(0, min(100, (int)($data['circuit_breaker_threshold'] ?? 50))), - 'failover_cooldown' => max(0, (int)($data['failover_cooldown'] ?? 10)), - 'remark' => trim((string)($data['remark'] ?? '')), - 'items' => $normalizedItems, - ]; - } - - private function resolveChannelFilters(Request $request, bool $withKeywords = false): array - { - $filters = []; - $merchantId = (int)$request->get('merchant_id', 0); - if ($merchantId > 0) $filters['merchant_id'] = $merchantId; - $merchantAppId = (int)$request->get('merchant_app_id', $request->get('app_id', 0)); - if ($merchantAppId > 0) $filters['merchant_app_id'] = $merchantAppId; - $methodCode = trim((string)$request->get('method_code', '')); - if ($methodCode !== '') { - $method = $this->methodRepository->findAnyByCode($methodCode); - $filters['method_id'] = $method ? (int)$method->id : -1; - } - $pluginCode = trim((string)$request->get('plugin_code', '')); - if ($pluginCode !== '') $filters['plugin_code'] = $pluginCode; - $status = $request->get('status', null); - if ($status !== null && $status !== '') $filters['status'] = (int)$status; - if ($withKeywords) { - $chanCode = trim((string)$request->get('chan_code', '')); - if ($chanCode !== '') $filters['chan_code'] = $chanCode; - $chanName = trim((string)$request->get('chan_name', '')); - if ($chanName !== '') $filters['chan_name'] = $chanName; - } - return $filters; - } - - private function resolveDays(mixed $daysInput): array - { - $days = max(1, min(30, (int)$daysInput)); - return [ - 'days' => $days, - 'created_from' => date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')), - 'created_to' => date('Y-m-d H:i:s'), - ]; - } - - private function buildMonitorRow(array $channel, array $stats): array - { - $totalOrders = (int)($stats['total_orders'] ?? 0); - $successOrders = (int)($stats['success_orders'] ?? 0); - $pendingOrders = (int)($stats['pending_orders'] ?? 0); - $failOrders = (int)($stats['fail_orders'] ?? 0); - $closedOrders = (int)($stats['closed_orders'] ?? 0); - $todayOrders = (int)($stats['today_orders'] ?? 0); - $todaySuccessOrders = (int)($stats['today_success_orders'] ?? 0); - $todaySuccessAmount = round((float)($stats['today_success_amount'] ?? 0), 2); - $successRate = $totalOrders > 0 ? round($successOrders / $totalOrders * 100, 2) : 0; - $dailyLimit = isset($channel['daily_limit']) ? (float)$channel['daily_limit'] : 0; - $dailyCnt = isset($channel['daily_cnt']) ? (int)$channel['daily_cnt'] : 0; - $todayLimitUsageRate = $dailyLimit > 0 ? round(min(100, ($todaySuccessAmount / $dailyLimit) * 100), 2) : null; - $healthScore = 0; - $healthLevel = 'disabled'; - $status = (int)($channel['status'] ?? 0); - - if ($status === 1) { - if ($totalOrders === 0) { - $healthScore = 60; - $healthLevel = 'idle'; - } else { - $healthScore = 90; - if ($successRate < 95) $healthScore -= 10; - if ($successRate < 80) $healthScore -= 15; - if ($successRate < 60) $healthScore -= 20; - if ($failOrders > 0) $healthScore -= min(15, $failOrders * 3); - if ($pendingOrders > max(3, (int)floor($successOrders / 2))) $healthScore -= 10; - if ($todayLimitUsageRate !== null && $todayLimitUsageRate >= 90) $healthScore -= 20; - elseif ($todayLimitUsageRate !== null && $todayLimitUsageRate >= 75) $healthScore -= 10; - $healthScore = max(0, min(100, $healthScore)); - if ($healthScore >= 80) $healthLevel = 'healthy'; - elseif ($healthScore >= 60) $healthLevel = 'warning'; - else $healthLevel = 'danger'; - } - } - - return [ - 'id' => (int)($channel['id'] ?? 0), - 'merchant_id' => (int)($channel['merchant_id'] ?? 0), - 'merchant_app_id' => (int)($channel['merchant_app_id'] ?? 0), - 'chan_code' => (string)($channel['chan_code'] ?? ''), - 'chan_name' => (string)($channel['chan_name'] ?? ''), - 'plugin_code' => (string)($channel['plugin_code'] ?? ''), - 'method_id' => (int)($channel['method_id'] ?? 0), - 'status' => $status, - 'sort' => (int)($channel['sort'] ?? 0), - 'daily_limit' => $dailyLimit > 0 ? round($dailyLimit, 2) : 0, - 'daily_cnt' => $dailyCnt > 0 ? $dailyCnt : 0, - 'min_amount' => $channel['min_amount'] === null ? null : round((float)$channel['min_amount'], 2), - 'max_amount' => $channel['max_amount'] === null ? null : round((float)$channel['max_amount'], 2), - 'total_orders' => $totalOrders, - 'success_orders' => $successOrders, - 'pending_orders' => $pendingOrders, - 'fail_orders' => $failOrders, - 'closed_orders' => $closedOrders, - 'today_orders' => $todayOrders, - 'today_success_orders' => $todaySuccessOrders, - 'total_amount' => round((float)($stats['total_amount'] ?? 0), 2), - 'success_amount' => round((float)($stats['success_amount'] ?? 0), 2), - 'today_amount' => round((float)($stats['today_amount'] ?? 0), 2), - 'today_success_amount' => $todaySuccessAmount, - 'last_order_at' => $stats['last_order_at'] ?? null, - 'last_success_at' => $stats['last_success_at'] ?? null, - 'success_rate' => $successRate, - 'today_limit_usage_rate' => $todayLimitUsageRate, - 'health_score' => $healthScore, - 'health_level' => $healthLevel, - ]; - } - - private function buildMonitorSummary(array $rows): array - { - $summary = [ - 'total_channels' => count($rows), - 'enabled_channels' => 0, - 'healthy_channels' => 0, - 'warning_channels' => 0, - 'danger_channels' => 0, - 'total_orders' => 0, - 'success_rate' => 0, - 'today_success_amount' => 0, - ]; - $successOrders = 0; - foreach ($rows as $row) { - if ((int)($row['status'] ?? 0) === 1) $summary['enabled_channels']++; - $level = $row['health_level'] ?? ''; - if ($level === 'healthy') $summary['healthy_channels']++; - elseif ($level === 'warning') $summary['warning_channels']++; - elseif ($level === 'danger') $summary['danger_channels']++; - $summary['total_orders'] += (int)($row['total_orders'] ?? 0); - $summary['today_success_amount'] = round($summary['today_success_amount'] + (float)($row['today_success_amount'] ?? 0), 2); - $successOrders += (int)($row['success_orders'] ?? 0); - } - if ($summary['total_orders'] > 0) { - $summary['success_rate'] = round($successOrders / $summary['total_orders'] * 100, 2); - } - return $summary; - } - - private function buildPollingRow(array $monitorRow, ?float $testAmount): array - { - $reasons = []; - $status = (int)($monitorRow['status'] ?? 0); - $dailyLimit = (float)($monitorRow['daily_limit'] ?? 0); - $dailyCnt = (int)($monitorRow['daily_cnt'] ?? 0); - $todaySuccessAmount = (float)($monitorRow['today_success_amount'] ?? 0); - $todayOrders = (int)($monitorRow['today_orders'] ?? 0); - $minAmount = $monitorRow['min_amount']; - $maxAmount = $monitorRow['max_amount']; - $remainingDailyLimit = $dailyLimit > 0 ? round($dailyLimit - $todaySuccessAmount, 2) : null; - $remainingDailyCount = $dailyCnt > 0 ? $dailyCnt - $todayOrders : null; - $routeState = 'ready'; - - if ($status !== 1) { $routeState = 'blocked'; $reasons[] = '通道已禁用'; } - if ($testAmount !== null) { - if ($minAmount !== null && $testAmount < (float)$minAmount) { $routeState = 'blocked'; $reasons[] = '低于最小支付金额'; } - if ($maxAmount !== null && (float)$maxAmount > 0 && $testAmount > (float)$maxAmount) { $routeState = 'blocked'; $reasons[] = '超过最大支付金额'; } - } - if ($remainingDailyLimit !== null && $remainingDailyLimit <= 0) { $routeState = 'blocked'; $reasons[] = '单日限额已用尽'; } - if ($remainingDailyCount !== null && $remainingDailyCount <= 0) { $routeState = 'blocked'; $reasons[] = '单日笔数已用尽'; } - if ($routeState !== 'blocked') { - if (($monitorRow['health_level'] ?? '') === 'warning' || ($monitorRow['health_level'] ?? '') === 'danger') { $routeState = 'degraded'; $reasons[] = '监控健康度偏低'; } - if ((int)($monitorRow['total_orders'] ?? 0) === 0) { $routeState = 'degraded'; $reasons[] = '暂无订单样本,建议灰度'; } - if ((float)($monitorRow['success_rate'] ?? 0) < 80 && (int)($monitorRow['total_orders'] ?? 0) > 0) { $routeState = 'degraded'; $reasons[] = '成功率偏低'; } - if ((int)($monitorRow['pending_orders'] ?? 0) > max(3, (int)($monitorRow['success_orders'] ?? 0))) { $routeState = 'degraded'; $reasons[] = '待支付订单偏多'; } - } - - $priorityBonus = max(0, 20 - min(20, (int)($monitorRow['sort'] ?? 0) * 2)); - $sampleBonus = (int)($monitorRow['total_orders'] ?? 0) > 0 ? min(10, (int)floor(((float)($monitorRow['success_rate'] ?? 0)) / 10)) : 5; - $routeScore = round(max(0, min(100, ((float)($monitorRow['health_score'] ?? 0) * 0.7) + $priorityBonus + $sampleBonus)), 2); - if ($routeState === 'degraded') $routeScore = max(0, round($routeScore - 15, 2)); - if ($routeState === 'blocked') $routeScore = 0; - - return array_merge($monitorRow, [ - 'route_state' => $routeState, - 'route_rank' => 0, - 'route_score' => $routeScore, - 'remaining_daily_limit' => $remainingDailyLimit === null ? null : round(max(0, $remainingDailyLimit), 2), - 'remaining_daily_count' => $remainingDailyCount === null ? null : max(0, $remainingDailyCount), - 'reasons' => array_values(array_unique($reasons)), - ]); - } - - private function buildPollingSummary(array $rows): array - { - $summary = [ - 'total_channels' => count($rows), - 'ready_channels' => 0, - 'degraded_channels' => 0, - 'blocked_channels' => 0, - 'recommended_channel' => null, - 'fallback_chain' => [], - ]; - foreach ($rows as $row) { - $state = $row['route_state'] ?? 'blocked'; - if ($state === 'ready') $summary['ready_channels']++; - elseif ($state === 'degraded') $summary['degraded_channels']++; - else $summary['blocked_channels']++; - } - foreach ($rows as $row) { - if ($summary['recommended_channel'] === null && ($row['route_state'] ?? '') !== 'blocked') { - $summary['recommended_channel'] = $row; - continue; - } - if (($row['route_state'] ?? '') !== 'blocked' && count($summary['fallback_chain']) < 5) { - $summary['fallback_chain'][] = sprintf('%s(%s)', (string)($row['chan_name'] ?? ''), (string)($row['chan_code'] ?? '')); - } - } - if ($summary['recommended_channel'] !== null) { - $recommendedId = (int)($summary['recommended_channel']['id'] ?? 0); - if ($recommendedId > 0) { - $summary['fallback_chain'] = []; - foreach ($rows as $row) { - if ((int)($row['id'] ?? 0) === $recommendedId || ($row['route_state'] ?? '') === 'blocked') continue; - $summary['fallback_chain'][] = sprintf('%s(%s)', (string)($row['chan_name'] ?? ''), (string)($row['chan_code'] ?? '')); - if (count($summary['fallback_chain']) >= 5) break; - } - } - } - return $summary; - } -} diff --git a/app/http/admin/controller/FinanceController.php b/app/http/admin/controller/FinanceController.php deleted file mode 100644 index 41c2e7c..0000000 --- a/app/http/admin/controller/FinanceController.php +++ /dev/null @@ -1,522 +0,0 @@ -get('page', 1); - $pageSize = (int)$request->get('page_size', 10); - $filters = $this->buildFilters($request); - $baseQuery = $this->buildOrderQuery($filters); - - $summaryRow = (clone $baseQuery) - ->selectRaw( - 'COUNT(*) AS total_orders, - SUM(CASE WHEN o.status = 1 THEN 1 ELSE 0 END) AS success_orders, - SUM(CASE WHEN o.status = 0 THEN 1 ELSE 0 END) AS pending_orders, - SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders, - COALESCE(SUM(o.amount), 0) AS total_amount, - COALESCE(SUM(o.fee), 0) AS total_fee, - COALESCE(SUM(o.real_amount - o.fee), 0) AS total_net_amount' - ) - ->first(); - - $paginator = (clone $baseQuery) - ->selectRaw( - "o.*, m.merchant_no, m.merchant_name, ma.app_id AS merchant_app_code, ma.app_name, pm.method_code, pm.method_name, - pc.chan_code, pc.chan_name, - COALESCE(o.real_amount - o.fee, 0) AS net_amount, - JSON_UNQUOTE(JSON_EXTRACT(o.extra, '$.routing.policy.policy_name')) AS route_policy_name" - ) - ->orderByDesc('o.id') - ->paginate($pageSize, ['*'], 'page', $page); - - $items = []; - foreach ($paginator->items() as $row) { - $item = (array)$row; - $item['reconcile_status'] = $this->reconcileStatus((int)($item['status'] ?? 0), (int)($item['notify_stat'] ?? 0)); - $item['reconcile_status_text'] = $this->reconcileStatusText($item['reconcile_status']); - $items[] = $item; - } - - return $this->success([ - 'summary' => [ - 'total_orders' => (int)($summaryRow->total_orders ?? 0), - 'success_orders' => (int)($summaryRow->success_orders ?? 0), - 'pending_orders' => (int)($summaryRow->pending_orders ?? 0), - 'notify_pending_orders' => (int)($summaryRow->notify_pending_orders ?? 0), - 'total_amount' => (string)($summaryRow->total_amount ?? '0.00'), - 'total_fee' => (string)($summaryRow->total_fee ?? '0.00'), - 'total_net_amount' => (string)($summaryRow->total_net_amount ?? '0.00'), - ], - 'list' => $items, - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'size' => $paginator->perPage(), - ]); - } - - public function settlement(Request $request) - { - $page = (int)$request->get('page', 1); - $pageSize = (int)$request->get('page_size', 10); - $filters = $this->buildFilters($request); - $baseQuery = $this->buildOrderQuery($filters)->where('o.status', 1); - - $summaryRow = (clone $baseQuery) - ->selectRaw( - 'COUNT(DISTINCT o.merchant_id) AS merchant_count, - COUNT(DISTINCT o.merchant_app_id) AS app_count, - COUNT(*) AS success_orders, - COALESCE(SUM(o.real_amount), 0) AS gross_amount, - COALESCE(SUM(o.fee), 0) AS fee_amount, - COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount, - SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders, - COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS notify_pending_amount' - ) - ->first(); - - $paginator = (clone $baseQuery) - ->selectRaw( - 'o.merchant_id, o.merchant_app_id, - m.merchant_no, m.merchant_name, ma.app_id AS merchant_app_code, ma.app_name, - COUNT(*) AS success_orders, - COUNT(DISTINCT o.channel_id) AS channel_count, - COUNT(DISTINCT o.method_id) AS method_count, - COALESCE(SUM(o.real_amount), 0) AS gross_amount, - COALESCE(SUM(o.fee), 0) AS fee_amount, - COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount, - SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders, - COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS notify_pending_amount, - MAX(o.pay_at) AS last_pay_at' - ) - ->groupBy('o.merchant_id', 'o.merchant_app_id', 'm.merchant_no', 'm.merchant_name', 'ma.app_id', 'ma.app_name') - ->orderByRaw('SUM(o.real_amount - o.fee) DESC') - ->paginate($pageSize, ['*'], 'page', $page); - - return $this->success([ - 'summary' => [ - 'merchant_count' => (int)($summaryRow->merchant_count ?? 0), - 'app_count' => (int)($summaryRow->app_count ?? 0), - 'success_orders' => (int)($summaryRow->success_orders ?? 0), - 'gross_amount' => (string)($summaryRow->gross_amount ?? '0.00'), - 'fee_amount' => (string)($summaryRow->fee_amount ?? '0.00'), - 'net_amount' => (string)($summaryRow->net_amount ?? '0.00'), - 'notify_pending_orders' => (int)($summaryRow->notify_pending_orders ?? 0), - 'notify_pending_amount' => (string)($summaryRow->notify_pending_amount ?? '0.00'), - ], - 'list' => array_map(fn ($row) => (array)$row, $paginator->items()), - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'size' => $paginator->perPage(), - ]); - } - - public function fee(Request $request) - { - $page = (int)$request->get('page', 1); - $pageSize = (int)$request->get('page_size', 10); - $filters = $this->buildFilters($request); - $baseQuery = $this->buildOrderQuery($filters); - - if (($filters['status'] ?? '') === '') { - $baseQuery->where('o.status', 1); - } - - $summaryRow = (clone $baseQuery) - ->selectRaw( - 'COUNT(DISTINCT o.merchant_id) AS merchant_count, - COUNT(DISTINCT o.channel_id) AS channel_count, - COUNT(DISTINCT o.method_id) AS method_count, - COUNT(*) AS order_count, - COALESCE(SUM(o.real_amount), 0) AS total_amount, - COALESCE(SUM(o.fee), 0) AS total_fee' - ) - ->first(); - - $paginator = (clone $baseQuery) - ->selectRaw( - 'o.merchant_id, o.channel_id, o.method_id, - m.merchant_no, m.merchant_name, - pm.method_code, pm.method_name, - pc.chan_code, pc.chan_name, - COUNT(*) AS order_count, - SUM(CASE WHEN o.status = 1 THEN 1 ELSE 0 END) AS success_orders, - COALESCE(SUM(o.real_amount), 0) AS total_amount, - COALESCE(SUM(o.fee), 0) AS total_fee, - COALESCE(AVG(CASE WHEN o.real_amount > 0 THEN o.fee / o.real_amount ELSE NULL END), 0) AS avg_fee_rate, - MAX(o.created_at) AS last_order_at' - ) - ->groupBy('o.merchant_id', 'o.channel_id', 'o.method_id', 'm.merchant_no', 'm.merchant_name', 'pm.method_code', 'pm.method_name', 'pc.chan_code', 'pc.chan_name') - ->orderByRaw('SUM(o.fee) DESC') - ->paginate($pageSize, ['*'], 'page', $page); - - $items = []; - foreach ($paginator->items() as $row) { - $item = (array)$row; - $item['avg_fee_rate_percent'] = round(((float)($item['avg_fee_rate'] ?? 0)) * 100, 4); - $items[] = $item; - } - - return $this->success([ - 'summary' => [ - 'merchant_count' => (int)($summaryRow->merchant_count ?? 0), - 'channel_count' => (int)($summaryRow->channel_count ?? 0), - 'method_count' => (int)($summaryRow->method_count ?? 0), - 'order_count' => (int)($summaryRow->order_count ?? 0), - 'total_amount' => (string)($summaryRow->total_amount ?? '0.00'), - 'total_fee' => (string)($summaryRow->total_fee ?? '0.00'), - ], - 'list' => $items, - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'size' => $paginator->perPage(), - ]); - } - - public function settlementRecord(Request $request) - { - $page = (int)$request->get('page', 1); - $pageSize = (int)$request->get('page_size', 10); - $filters = $this->buildFilters($request); - $baseQuery = $this->buildOrderQuery($filters)->where('o.status', 1); - - $summaryRow = (clone $baseQuery) - ->selectRaw( - "COUNT(DISTINCT CONCAT(o.merchant_app_id, '#', DATE(COALESCE(o.pay_at, o.created_at)))) AS record_count, - COUNT(DISTINCT o.merchant_id) AS merchant_count, - COUNT(DISTINCT o.merchant_app_id) AS app_count, - COUNT(*) AS success_orders, - COALESCE(SUM(o.real_amount), 0) AS gross_amount, - COALESCE(SUM(o.fee), 0) AS fee_amount, - COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount, - SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders" - ) - ->first(); - - $paginator = (clone $baseQuery) - ->selectRaw( - "DATE(COALESCE(o.pay_at, o.created_at)) AS settlement_date, - o.merchant_id, o.merchant_app_id, - m.merchant_no, m.merchant_name, ma.app_id AS merchant_app_code, ma.app_name, - COUNT(*) AS success_orders, - COALESCE(SUM(o.real_amount), 0) AS gross_amount, - COALESCE(SUM(o.fee), 0) AS fee_amount, - COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount, - SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders, - MAX(o.pay_at) AS last_pay_at" - ) - ->groupByRaw("DATE(COALESCE(o.pay_at, o.created_at)), o.merchant_id, o.merchant_app_id, m.merchant_no, m.merchant_name, ma.app_id, ma.app_name") - ->orderByDesc('settlement_date') - ->orderByRaw('SUM(o.real_amount - o.fee) DESC') - ->paginate($pageSize, ['*'], 'page', $page); - - $items = []; - foreach ($paginator->items() as $row) { - $item = (array)$row; - $item['settlement_status'] = (int)($item['notify_pending_orders'] ?? 0) > 0 ? 'pending' : 'ready'; - $item['settlement_status_text'] = $item['settlement_status'] === 'ready' ? 'ready' : 'pending_notify'; - $items[] = $item; - } - - return $this->success([ - 'summary' => [ - 'record_count' => (int)($summaryRow->record_count ?? 0), - 'merchant_count' => (int)($summaryRow->merchant_count ?? 0), - 'app_count' => (int)($summaryRow->app_count ?? 0), - 'success_orders' => (int)($summaryRow->success_orders ?? 0), - 'gross_amount' => (string)($summaryRow->gross_amount ?? '0.00'), - 'fee_amount' => (string)($summaryRow->fee_amount ?? '0.00'), - 'net_amount' => (string)($summaryRow->net_amount ?? '0.00'), - 'notify_pending_orders' => (int)($summaryRow->notify_pending_orders ?? 0), - ], - 'list' => $items, - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'size' => $paginator->perPage(), - ]); - } - - public function batchSettlement(Request $request) - { - $page = (int)$request->get('page', 1); - $pageSize = (int)$request->get('page_size', 10); - $filters = $this->buildFilters($request); - $baseQuery = $this->buildOrderQuery($filters)->where('o.status', 1); - - $summaryRow = (clone $baseQuery) - ->selectRaw( - "COUNT(DISTINCT o.merchant_id) AS merchant_count, - COUNT(DISTINCT o.merchant_app_id) AS app_count, - COUNT(*) AS success_orders, - COUNT(DISTINCT CONCAT(o.merchant_app_id, '#', DATE(COALESCE(o.pay_at, o.created_at)))) AS batch_days, - COALESCE(SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS ready_amount, - COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS pending_amount" - ) - ->first(); - - $paginator = (clone $baseQuery) - ->selectRaw( - "o.merchant_id, o.merchant_app_id, - m.merchant_no, m.merchant_name, ma.app_id AS merchant_app_code, ma.app_name, - COUNT(*) AS success_orders, - COUNT(DISTINCT DATE(COALESCE(o.pay_at, o.created_at))) AS batch_days, - COALESCE(SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS ready_amount, - COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS pending_amount, - SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS pending_notify_orders, - MAX(o.pay_at) AS last_pay_at" - ) - ->groupBy('o.merchant_id', 'o.merchant_app_id', 'm.merchant_no', 'm.merchant_name', 'ma.app_id', 'ma.app_name') - ->orderByRaw('SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END) DESC') - ->paginate($pageSize, ['*'], 'page', $page); - - $items = []; - foreach ($paginator->items() as $row) { - $item = (array)$row; - $item['batch_status'] = (float)($item['pending_amount'] ?? 0) > 0 ? 'pending' : 'ready'; - $item['batch_status_text'] = $item['batch_status'] === 'ready' ? 'ready_to_batch' : 'pending_notify'; - $items[] = $item; - } - - return $this->success([ - 'summary' => [ - 'merchant_count' => (int)($summaryRow->merchant_count ?? 0), - 'app_count' => (int)($summaryRow->app_count ?? 0), - 'success_orders' => (int)($summaryRow->success_orders ?? 0), - 'batch_days' => (int)($summaryRow->batch_days ?? 0), - 'ready_amount' => (string)($summaryRow->ready_amount ?? '0.00'), - 'pending_amount' => (string)($summaryRow->pending_amount ?? '0.00'), - ], - 'list' => $items, - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'size' => $paginator->perPage(), - ]); - } - - public function split(Request $request) - { - $page = (int)$request->get('page', 1); - $pageSize = (int)$request->get('page_size', 10); - $filters = $this->buildFilters($request); - $baseQuery = $this->buildOrderQuery($filters)->where('o.status', 1); - - $summaryRow = (clone $baseQuery) - ->selectRaw( - 'COUNT(DISTINCT o.channel_id) AS channel_count, - COUNT(DISTINCT o.merchant_id) AS merchant_count, - COUNT(*) AS order_count, - COALESCE(SUM(o.real_amount), 0) AS gross_amount, - COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount, - COALESCE(SUM((o.real_amount - o.fee) * COALESCE(pc.split_ratio, 100) / 100), 0) AS merchant_share_amount, - COALESCE(SUM((o.real_amount - o.fee) * (100 - COALESCE(pc.split_ratio, 100)) / 100), 0) AS platform_share_amount, - COALESCE(SUM(o.real_amount * COALESCE(pc.chan_cost, 0) / 100), 0) AS channel_cost_amount' - ) - ->first(); - - $paginator = (clone $baseQuery) - ->selectRaw( - 'o.merchant_id, o.channel_id, o.method_id, - m.merchant_no, m.merchant_name, - pm.method_code, pm.method_name, - pc.chan_code, pc.chan_name, - COALESCE(pc.split_ratio, 100) AS split_ratio, - COALESCE(pc.chan_cost, 0) AS chan_cost, - COUNT(*) AS order_count, - COALESCE(SUM(o.real_amount), 0) AS gross_amount, - COALESCE(SUM(o.fee), 0) AS fee_amount, - COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount, - COALESCE(SUM((o.real_amount - o.fee) * COALESCE(pc.split_ratio, 100) / 100), 0) AS merchant_share_amount, - COALESCE(SUM((o.real_amount - o.fee) * (100 - COALESCE(pc.split_ratio, 100)) / 100), 0) AS platform_share_amount, - COALESCE(SUM(o.real_amount * COALESCE(pc.chan_cost, 0) / 100), 0) AS channel_cost_amount, - MAX(o.pay_at) AS last_pay_at' - ) - ->groupBy('o.merchant_id', 'o.channel_id', 'o.method_id', 'm.merchant_no', 'm.merchant_name', 'pm.method_code', 'pm.method_name', 'pc.chan_code', 'pc.chan_name', 'pc.split_ratio', 'pc.chan_cost') - ->orderByRaw('SUM((o.real_amount - o.fee) * COALESCE(pc.split_ratio, 100) / 100) DESC') - ->paginate($pageSize, ['*'], 'page', $page); - - return $this->success([ - 'summary' => [ - 'channel_count' => (int)($summaryRow->channel_count ?? 0), - 'merchant_count' => (int)($summaryRow->merchant_count ?? 0), - 'order_count' => (int)($summaryRow->order_count ?? 0), - 'gross_amount' => (string)($summaryRow->gross_amount ?? '0.00'), - 'net_amount' => (string)($summaryRow->net_amount ?? '0.00'), - 'merchant_share_amount' => (string)($summaryRow->merchant_share_amount ?? '0.00'), - 'platform_share_amount' => (string)($summaryRow->platform_share_amount ?? '0.00'), - 'channel_cost_amount' => (string)($summaryRow->channel_cost_amount ?? '0.00'), - ], - 'list' => array_map(fn ($row) => (array)$row, $paginator->items()), - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'size' => $paginator->perPage(), - ]); - } - - public function invoice(Request $request) - { - $page = (int)$request->get('page', 1); - $pageSize = (int)$request->get('page_size', 10); - $filters = $this->buildFilters($request); - $baseQuery = $this->buildOrderQuery($filters)->where('o.status', 1); - - $summaryRow = (clone $baseQuery) - ->selectRaw( - 'COUNT(DISTINCT o.merchant_id) AS merchant_count, - COUNT(DISTINCT o.merchant_app_id) AS app_count, - COUNT(*) AS success_orders, - COALESCE(SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS invoiceable_amount, - COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS pending_invoice_amount, - SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS pending_notify_orders' - ) - ->first(); - - $paginator = (clone $baseQuery) - ->selectRaw( - 'o.merchant_id, o.merchant_app_id, - m.merchant_no, m.merchant_name, ma.app_id AS merchant_app_code, ma.app_name, - COUNT(*) AS success_orders, - COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount, - COALESCE(SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS invoiceable_amount, - COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS pending_invoice_amount, - SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS pending_notify_orders, - MAX(o.pay_at) AS last_pay_at' - ) - ->groupBy('o.merchant_id', 'o.merchant_app_id', 'm.merchant_no', 'm.merchant_name', 'ma.app_id', 'ma.app_name') - ->orderByRaw('SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END) DESC') - ->paginate($pageSize, ['*'], 'page', $page); - - $items = []; - foreach ($paginator->items() as $row) { - $item = (array)$row; - $item['invoice_status'] = (float)($item['pending_invoice_amount'] ?? 0) > 0 ? 'pending' : 'ready'; - $item['invoice_status_text'] = $item['invoice_status'] === 'ready' ? 'ready' : 'pending_review'; - $items[] = $item; - } - - return $this->success([ - 'summary' => [ - 'merchant_count' => (int)($summaryRow->merchant_count ?? 0), - 'app_count' => (int)($summaryRow->app_count ?? 0), - 'success_orders' => (int)($summaryRow->success_orders ?? 0), - 'invoiceable_amount' => (string)($summaryRow->invoiceable_amount ?? '0.00'), - 'pending_invoice_amount' => (string)($summaryRow->pending_invoice_amount ?? '0.00'), - 'pending_notify_orders' => (int)($summaryRow->pending_notify_orders ?? 0), - ], - 'list' => $items, - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'size' => $paginator->perPage(), - ]); - } - - private function buildFilters(Request $request): array - { - $methodCode = trim((string)$request->get('method_code', '')); - $methodId = 0; - if ($methodCode !== '') { - $method = $this->methodRepository->findAnyByCode($methodCode); - $methodId = $method ? (int)$method->id : 0; - } - - return [ - 'merchant_id' => (int)$request->get('merchant_id', 0), - 'merchant_app_id' => (int)$request->get('merchant_app_id', 0), - 'method_id' => $methodId, - 'channel_id' => (int)$request->get('channel_id', 0), - 'status' => (string)$request->get('status', ''), - 'notify_stat' => (string)$request->get('notify_stat', ''), - 'order_id' => trim((string)$request->get('order_id', '')), - 'mch_order_no' => trim((string)$request->get('mch_order_no', '')), - 'created_from' => trim((string)$request->get('created_from', '')), - 'created_to' => trim((string)$request->get('created_to', '')), - ]; - } - - private function buildOrderQuery(array $filters) - { - $query = Db::table('ma_pay_order as o') - ->leftJoin('ma_merchant as m', 'm.id', '=', 'o.merchant_id') - ->leftJoin('ma_merchant_app as ma', 'ma.id', '=', 'o.merchant_app_id') - ->leftJoin('ma_pay_method as pm', 'pm.id', '=', 'o.method_id') - ->leftJoin('ma_pay_channel as pc', 'pc.id', '=', 'o.channel_id'); - - if (!empty($filters['merchant_id'])) { - $query->where('o.merchant_id', (int)$filters['merchant_id']); - } - if (!empty($filters['merchant_app_id'])) { - $query->where('o.merchant_app_id', (int)$filters['merchant_app_id']); - } - if (!empty($filters['method_id'])) { - $query->where('o.method_id', (int)$filters['method_id']); - } - if (!empty($filters['channel_id'])) { - $query->where('o.channel_id', (int)$filters['channel_id']); - } - if (($filters['status'] ?? '') !== '') { - $query->where('o.status', (int)$filters['status']); - } - if (($filters['notify_stat'] ?? '') !== '') { - $query->where('o.notify_stat', (int)$filters['notify_stat']); - } - if (!empty($filters['order_id'])) { - $query->where('o.order_id', 'like', '%' . $filters['order_id'] . '%'); - } - if (!empty($filters['mch_order_no'])) { - $query->where('o.mch_order_no', 'like', '%' . $filters['mch_order_no'] . '%'); - } - if (!empty($filters['created_from'])) { - $query->where('o.created_at', '>=', $filters['created_from']); - } - if (!empty($filters['created_to'])) { - $query->where('o.created_at', '<=', $filters['created_to']); - } - - return $query; - } - - private function reconcileStatus(int $status, int $notifyStat): string - { - if ($status === 1 && $notifyStat === 1) { - return 'matched'; - } - if ($status === 1 && $notifyStat === 0) { - return 'notify_pending'; - } - if ($status === 0) { - return 'pending'; - } - if ($status === 2) { - return 'failed'; - } - if ($status === 3) { - return 'closed'; - } - - return 'unknown'; - } - - private function reconcileStatusText(string $status): string - { - return match ($status) { - 'matched' => 'matched', - 'notify_pending' => 'notify_pending', - 'pending' => 'pending', - 'failed' => 'failed', - 'closed' => 'closed', - default => 'unknown', - }; - } -} diff --git a/app/http/admin/controller/MenuController.php b/app/http/admin/controller/MenuController.php deleted file mode 100644 index 6009d5d..0000000 --- a/app/http/admin/controller/MenuController.php +++ /dev/null @@ -1,25 +0,0 @@ -menuService->getRouters(); - return $this->success($routers); - } -} - diff --git a/app/http/admin/controller/MerchantAppController.php b/app/http/admin/controller/MerchantAppController.php deleted file mode 100644 index c977816..0000000 --- a/app/http/admin/controller/MerchantAppController.php +++ /dev/null @@ -1,303 +0,0 @@ -get('page', 1); - $pageSize = (int)$request->get('page_size', 10); - - $filters = [ - 'merchant_id' => (int)$request->get('merchant_id', 0), - 'status' => $request->get('status', ''), - 'app_id' => trim((string)$request->get('app_id', '')), - 'app_name' => trim((string)$request->get('app_name', '')), - 'api_type' => trim((string)$request->get('api_type', '')), - ]; - - $paginator = $this->merchantAppRepository->searchPaginate($filters, $page, $pageSize); - $packageMap = $this->buildPackageMap(); - $items = []; - foreach ($paginator->items() as $row) { - $item = method_exists($row, 'toArray') ? $row->toArray() : (array)$row; - $packageCode = trim((string)($item['package_code'] ?? '')); - $item['package_code'] = $packageCode; - $item['package_name'] = $packageCode !== '' ? ($packageMap[$packageCode] ?? $packageCode) : ''; - $items[] = $item; - } - - return $this->success([ - 'list' => $items, - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'size' => $paginator->perPage(), - ]); - } - - public function detail(Request $request) - { - $id = (int)$request->get('id', 0); - if ($id <= 0) { - return $this->fail('app id is required', 400); - } - - $row = $this->merchantAppRepository->find($id); - if (!$row) { - return $this->fail('app not found', 404); - } - - return $this->success(method_exists($row, 'toArray') ? $row->toArray() : (array)$row); - } - - public function configDetail(Request $request) - { - $id = (int)$request->get('id', 0); - if ($id <= 0) { - return $this->fail('app id is required', 400); - } - - $app = $this->merchantAppRepository->find($id); - if (!$app) { - return $this->fail('app not found', 404); - } - - $appRow = method_exists($app, 'toArray') ? $app->toArray() : (array)$app; - $config = $this->buildAppConfig($appRow); - return $this->success([ - 'app' => $app, - 'config' => $config, - ]); - } - - public function save(Request $request) - { - $data = $request->post(); - $id = (int)($data['id'] ?? 0); - - $merchantId = (int)($data['merchant_id'] ?? 0); - $apiType = trim((string)($data['api_type'] ?? 'epay')); - $appId = trim((string)($data['app_id'] ?? '')); - $appName = trim((string)($data['app_name'] ?? '')); - $status = (int)($data['status'] ?? 1); - - if ($merchantId <= 0 || $appId === '' || $appName === '') { - return $this->fail('merchant_id, app_id and app_name are required', 400); - } - - $merchant = $this->merchantRepository->find($merchantId); - if (!$merchant) { - return $this->fail('merchant not found', 404); - } - - if (!in_array($apiType, ['openapi', 'epay', 'custom', 'default'], true)) { - return $this->fail('invalid api_type', 400); - } - - if ($id > 0) { - $row = $this->merchantAppRepository->find($id); - if (!$row) { - return $this->fail('app not found', 404); - } - - if ($row->app_id !== $appId) { - $exists = $this->merchantAppRepository->findAnyByAppId($appId); - if ($exists) { - return $this->fail('app_id already exists', 400); - } - } - - $update = [ - 'mer_id' => $merchantId, - 'api_type' => $apiType, - 'app_code' => $appId, - 'app_name' => $appName, - 'status' => $status, - 'updated_at' => date('Y-m-d H:i:s'), - ]; - - if (!empty($data['app_secret'])) { - $update['app_secret'] = (string)$data['app_secret']; - } - - $this->merchantAppRepository->updateById($id, $update); - } else { - $exists = $this->merchantAppRepository->findAnyByAppId($appId); - if ($exists) { - return $this->fail('app_id already exists', 400); - } - - $secret = !empty($data['app_secret']) ? (string)$data['app_secret'] : $this->generateSecret(); - $this->merchantAppRepository->create([ - 'mer_id' => $merchantId, - 'api_type' => $apiType, - 'app_code' => $appId, - 'app_secret' => $secret, - 'app_name' => $appName, - 'status' => $status, - 'created_at' => date('Y-m-d H:i:s'), - 'updated_at' => date('Y-m-d H:i:s'), - ]); - } - - return $this->success(null, 'saved'); - } - - public function resetSecret(Request $request) - { - $id = (int)$request->post('id', 0); - if ($id <= 0) { - return $this->fail('app id is required', 400); - } - - $row = $this->merchantAppRepository->find($id); - if (!$row) { - return $this->fail('app not found', 404); - } - - $secret = $this->generateSecret(); - $this->merchantAppRepository->updateById($id, ['app_secret' => $secret]); - - return $this->success(['app_secret' => $secret], 'reset success'); - } - - public function toggle(Request $request) - { - $id = (int)$request->post('id', 0); - $status = $request->post('status', null); - - if ($id <= 0 || $status === null) { - return $this->fail('invalid params', 400); - } - - $ok = $this->merchantAppRepository->updateById($id, ['status' => (int)$status]); - return $ok ? $this->success(null, 'updated') : $this->fail('update failed', 500); - } - - public function configSave(Request $request) - { - $id = (int)$request->post('id', 0); - if ($id <= 0) { - return $this->fail('app id is required', 400); - } - - $app = $this->merchantAppRepository->find($id); - if (!$app) { - return $this->fail('app not found', 404); - } - - $signType = trim((string)$request->post('sign_type', 'md5')); - $callbackMode = trim((string)$request->post('callback_mode', 'server')); - if (!in_array($signType, ['md5', 'sha256', 'hmac-sha256'], true)) { - return $this->fail('invalid sign_type', 400); - } - if (!in_array($callbackMode, ['server', 'server+page', 'manual'], true)) { - return $this->fail('invalid callback_mode', 400); - } - - $config = [ - 'package_code' => trim((string)$request->post('package_code', '')), - 'notify_url' => trim((string)$request->post('notify_url', '')), - 'return_url' => trim((string)$request->post('return_url', '')), - 'callback_mode' => $callbackMode, - 'sign_type' => $signType, - 'order_expire_minutes' => max(0, (int)$request->post('order_expire_minutes', 30)), - 'callback_retry_limit' => max(0, (int)$request->post('callback_retry_limit', 6)), - 'ip_whitelist' => trim((string)$request->post('ip_whitelist', '')), - 'amount_min' => max(0, (float)$request->post('amount_min', 0)), - 'amount_max' => max(0, (float)$request->post('amount_max', 0)), - 'daily_limit' => max(0, (float)$request->post('daily_limit', 0)), - 'notify_enabled' => (int)$request->post('notify_enabled', 1) === 1 ? 1 : 0, - 'remark' => trim((string)$request->post('remark', '')), - 'updated_at' => date('Y-m-d H:i:s'), - ]; - - if ($config['package_code'] !== '') { - $packageExists = false; - foreach ($this->getConfigEntries('merchant_packages') as $package) { - if (($package['package_code'] ?? '') === $config['package_code']) { - $packageExists = true; - break; - } - } - if (!$packageExists) { - return $this->fail('package_code not found', 400); - } - } - - $updateData = $config; - $updateData['updated_at'] = date('Y-m-d H:i:s'); - $this->merchantAppRepository->updateById($id, $updateData); - - return $this->success(null, 'saved'); - } - - private function generateSecret(): string - { - $raw = random_bytes(24); - return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); - } - - private function getConfigEntries(string $configKey): array - { - $raw = $this->systemConfigService->getValue($configKey, '[]'); - if (!is_string($raw) || $raw === '') { - return []; - } - - $decoded = json_decode($raw, true); - if (!is_array($decoded)) { - return []; - } - - return array_values(array_filter($decoded, 'is_array')); - } - - private function buildAppConfig(array $app): array - { - return [ - 'package_code' => trim((string)($app['package_code'] ?? '')), - 'notify_url' => trim((string)($app['notify_url'] ?? '')), - 'return_url' => trim((string)($app['return_url'] ?? '')), - 'callback_mode' => trim((string)($app['callback_mode'] ?? 'server')) ?: 'server', - 'sign_type' => trim((string)($app['sign_type'] ?? 'md5')) ?: 'md5', - 'order_expire_minutes' => (int)($app['order_expire_minutes'] ?? 30), - 'callback_retry_limit' => (int)($app['callback_retry_limit'] ?? 6), - 'ip_whitelist' => trim((string)($app['ip_whitelist'] ?? '')), - 'amount_min' => (string)($app['amount_min'] ?? '0.00'), - 'amount_max' => (string)($app['amount_max'] ?? '0.00'), - 'daily_limit' => (string)($app['daily_limit'] ?? '0.00'), - 'notify_enabled' => (int)($app['notify_enabled'] ?? 1), - 'remark' => trim((string)($app['remark'] ?? '')), - 'updated_at' => (string)($app['updated_at'] ?? ''), - ]; - } - - private function buildPackageMap(): array - { - $map = []; - foreach ($this->getConfigEntries('merchant_packages') as $package) { - $packageCode = trim((string)($package['package_code'] ?? '')); - if ($packageCode === '') { - continue; - } - $map[$packageCode] = trim((string)($package['package_name'] ?? $packageCode)); - } - - return $map; - } -} diff --git a/app/http/admin/controller/MerchantController.php b/app/http/admin/controller/MerchantController.php deleted file mode 100644 index 51c00cc..0000000 --- a/app/http/admin/controller/MerchantController.php +++ /dev/null @@ -1,884 +0,0 @@ -get('page', 1); - $pageSize = (int)$request->get('page_size', 10); - - $filters = [ - 'status' => $request->get('status', ''), - 'merchant_no' => trim((string)$request->get('merchant_no', '')), - 'merchant_name' => trim((string)$request->get('merchant_name', '')), - 'email' => trim((string)$request->get('email', '')), - 'balance' => trim((string)$request->get('balance', '')), - ]; - - $paginator = $this->merchantRepository->searchPaginate($filters, $page, $pageSize); - $items = []; - foreach ($paginator->items() as $row) { - $item = method_exists($row, 'toArray') ? $row->toArray() : (array)$row; - $items[] = $this->normalizeMerchantRow($item); - } - - return $this->success([ - 'list' => $items, - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'size' => $paginator->perPage(), - ]); - } - - public function detail(Request $request) - { - $id = (int)$request->get('id', 0); - if ($id <= 0) { - return $this->fail('merchant id is required', 400); - } - - $row = $this->merchantRepository->find($id); - if (!$row) { - return $this->fail('merchant not found', 404); - } - - $merchant = method_exists($row, 'toArray') ? $row->toArray() : (array)$row; - return $this->success($this->normalizeMerchantRow($merchant)); - } - - public function profileDetail(Request $request) - { - $id = (int)$request->get('id', 0); - if ($id <= 0) { - return $this->fail('merchant id is required', 400); - } - - $merchant = $this->merchantRepository->find($id); - if (!$merchant) { - return $this->fail('merchant not found', 404); - } - - $merchantRow = method_exists($merchant, 'toArray') ? $merchant->toArray() : (array)$merchant; - return $this->success([ - 'merchant' => $this->normalizeMerchantRow($merchantRow), - 'profile' => $this->buildMerchantProfile($merchantRow), - ]); - } - - public function save(Request $request) - { - $data = $request->post(); - $id = (int)($data['id'] ?? 0); - - $merchantNo = trim((string)($data['merchant_no'] ?? '')); - $merchantName = trim((string)($data['merchant_name'] ?? '')); - $balance = max(0, (float)($data['balance'] ?? 0)); - $email = trim((string)($data['email'] ?? $data['notify_email'] ?? '')); - $status = (int)($data['status'] ?? 1); - $remark = trim((string)($data['remark'] ?? '')); - - if ($merchantNo === '' || $merchantName === '') { - return $this->fail('merchant_no and merchant_name are required', 400); - } - - if ($id > 0) { - $this->merchantRepository->updateById($id, [ - 'merchant_no' => $merchantNo, - 'merchant_name' => $merchantName, - 'balance' => $balance, - 'email' => $email, - 'status' => $status, - 'remark' => $remark, - 'updated_at' => date('Y-m-d H:i:s'), - ]); - } else { - $exists = $this->merchantRepository->findByMerchantNo($merchantNo); - if ($exists) { - return $this->fail('merchant_no already exists', 400); - } - - $this->merchantRepository->create([ - 'merchant_no' => $merchantNo, - 'merchant_name' => $merchantName, - 'balance' => $balance, - 'email' => $email, - 'status' => $status, - 'remark' => $remark, - 'created_at' => date('Y-m-d H:i:s'), - 'updated_at' => date('Y-m-d H:i:s'), - ]); - } - - return $this->success(null, 'saved'); - } - - public function toggle(Request $request) - { - $id = (int)$request->post('id', 0); - $status = $request->post('status', null); - - if ($id <= 0 || $status === null) { - return $this->fail('invalid params', 400); - } - - $ok = $this->merchantRepository->updateById($id, ['status' => (int)$status]); - return $ok ? $this->success(null, 'updated') : $this->fail('update failed', 500); - } - - public function profileSave(Request $request) - { - $merchantId = (int)$request->post('merchant_id', 0); - if ($merchantId <= 0) { - return $this->fail('merchant_id is required', 400); - } - - $merchant = $this->merchantRepository->find($merchantId); - if (!$merchant) { - return $this->fail('merchant not found', 404); - } - - $merchantRow = method_exists($merchant, 'toArray') ? $merchant->toArray() : (array)$merchant; - - $profile = [ - 'email' => trim((string)$request->post('email', $request->post('notify_email', ''))), - 'remark' => trim((string)$request->post('remark', '')), - 'balance' => max(0, (float)$request->post('balance', $merchantRow['balance'] ?? 0)), - ]; - - $updateData = $profile; - $updateData['updated_at'] = date('Y-m-d H:i:s'); - $this->merchantRepository->updateById($merchantId, $updateData); - - return $this->success(null, 'saved'); - } - - public function statistics(Request $request) - { - $page = (int)$request->get('page', 1); - $pageSize = (int)$request->get('page_size', 10); - $filters = $this->buildOpFilters($request); - - $summaryQuery = Db::table('ma_mer as m') - ->leftJoin('ma_pay_app as ma', 'ma.mer_id', '=', 'm.id') - ->leftJoin('ma_pay_channel as pc', 'pc.mer_id', '=', 'm.id') - ->leftJoin('ma_pay_order as o', function ($join) use ($filters) { - $join->on('o.merchant_id', '=', 'm.id'); - if (!empty($filters['created_from'])) { - $join->where('o.created_at', '>=', $filters['created_from']); - } - if (!empty($filters['created_to'])) { - $join->where('o.created_at', '<=', $filters['created_to']); - } - }); - $this->applyMerchantFilters($summaryQuery, $filters); - - $summaryRow = $summaryQuery - ->selectRaw( - 'COUNT(DISTINCT m.id) AS merchant_count, - COUNT(DISTINCT CASE WHEN m.status = 1 THEN m.id END) AS active_merchant_count, - COUNT(DISTINCT ma.id) AS app_count, - COUNT(DISTINCT pc.id) AS channel_count, - COUNT(DISTINCT o.id) AS order_count, - COUNT(DISTINCT CASE WHEN o.status = 1 THEN o.id END) AS success_order_count, - COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount ELSE 0 END), 0) AS success_amount, - COALESCE(SUM(CASE WHEN o.status = 1 THEN o.fee ELSE 0 END), 0) AS fee_amount' - ) - ->first(); - - $listQuery = Db::table('ma_mer as m') - ->leftJoin('ma_pay_app as ma', 'ma.mer_id', '=', 'm.id') - ->leftJoin('ma_pay_channel as pc', 'pc.mer_id', '=', 'm.id') - ->leftJoin('ma_pay_order as o', function ($join) use ($filters) { - $join->on('o.merchant_id', '=', 'm.id'); - if (!empty($filters['created_from'])) { - $join->where('o.created_at', '>=', $filters['created_from']); - } - if (!empty($filters['created_to'])) { - $join->where('o.created_at', '<=', $filters['created_to']); - } - }); - $this->applyMerchantFilters($listQuery, $filters); - - $paginator = $listQuery - ->selectRaw( - 'm.id, m.merchant_no, m.merchant_name, m.balance, m.email, m.status, m.remark, m.created_at, - COUNT(DISTINCT ma.id) AS app_count, - COUNT(DISTINCT CASE WHEN ma.status = 1 THEN ma.id END) AS active_app_count, - COUNT(DISTINCT pc.id) AS channel_count, - COUNT(DISTINCT o.id) AS order_count, - COUNT(DISTINCT CASE WHEN o.status = 1 THEN o.id END) AS success_order_count, - COUNT(DISTINCT CASE WHEN o.status = 0 THEN o.id END) AS pending_order_count, - COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount ELSE 0 END), 0) AS success_amount, - COALESCE(SUM(CASE WHEN o.status = 1 THEN o.fee ELSE 0 END), 0) AS fee_amount, - MAX(o.created_at) AS last_order_at' - ) - ->groupBy('m.id', 'm.merchant_no', 'm.merchant_name', 'm.balance', 'm.email', 'm.status', 'm.remark', 'm.created_at') - ->orderByDesc('m.id') - ->paginate($pageSize, ['*'], 'page', $page); - - return $this->success([ - 'summary' => [ - 'merchant_count' => (int)($summaryRow->merchant_count ?? 0), - 'active_merchant_count' => (int)($summaryRow->active_merchant_count ?? 0), - 'app_count' => (int)($summaryRow->app_count ?? 0), - 'channel_count' => (int)($summaryRow->channel_count ?? 0), - 'order_count' => (int)($summaryRow->order_count ?? 0), - 'success_order_count' => (int)($summaryRow->success_order_count ?? 0), - 'success_amount' => (string)($summaryRow->success_amount ?? '0.00'), - 'fee_amount' => (string)($summaryRow->fee_amount ?? '0.00'), - ], - 'list' => array_map(fn ($row) => (array)$row, $paginator->items()), - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'size' => $paginator->perPage(), - ]); - } - - public function funds(Request $request) - { - $page = (int)$request->get('page', 1); - $pageSize = (int)$request->get('page_size', 10); - $filters = $this->buildOpFilters($request); - - $summaryQuery = Db::table('ma_mer as m') - ->leftJoin('ma_pay_order as o', function ($join) use ($filters) { - $join->on('o.merchant_id', '=', 'm.id'); - if (!empty($filters['created_from'])) { - $join->where('o.created_at', '>=', $filters['created_from']); - } - if (!empty($filters['created_to'])) { - $join->where('o.created_at', '<=', $filters['created_to']); - } - }); - $this->applyMerchantFilters($summaryQuery, $filters); - - $summaryRow = $summaryQuery - ->selectRaw( - 'COUNT(DISTINCT m.id) AS merchant_count, - COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount ELSE 0 END), 0) AS settled_amount, - COALESCE(SUM(CASE WHEN o.status = 0 THEN o.amount ELSE 0 END), 0) AS pending_amount, - COALESCE(SUM(CASE WHEN o.status = 1 THEN o.fee ELSE 0 END), 0) AS fee_amount, - COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS net_amount, - COUNT(DISTINCT CASE WHEN o.notify_stat = 0 THEN o.id END) AS notify_pending_orders' - ) - ->first(); - - $listQuery = Db::table('ma_mer as m') - ->leftJoin('ma_pay_order as o', function ($join) use ($filters) { - $join->on('o.merchant_id', '=', 'm.id'); - if (!empty($filters['created_from'])) { - $join->where('o.created_at', '>=', $filters['created_from']); - } - if (!empty($filters['created_to'])) { - $join->where('o.created_at', '<=', $filters['created_to']); - } - }); - $this->applyMerchantFilters($listQuery, $filters); - - $paginator = $listQuery - ->selectRaw( - 'm.id, m.merchant_no, m.merchant_name, m.balance, m.email, m.status, m.remark, m.created_at, - COUNT(DISTINCT CASE WHEN o.status = 1 THEN o.id END) AS success_order_count, - COUNT(DISTINCT CASE WHEN o.status = 0 THEN o.id END) AS pending_order_count, - COUNT(DISTINCT CASE WHEN o.notify_stat = 0 THEN o.id END) AS notify_pending_orders, - COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount ELSE 0 END), 0) AS settled_amount, - COALESCE(SUM(CASE WHEN o.status = 0 THEN o.amount ELSE 0 END), 0) AS pending_amount, - COALESCE(SUM(CASE WHEN o.status = 1 THEN o.fee ELSE 0 END), 0) AS fee_amount, - COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS net_amount, - MAX(o.pay_at) AS last_pay_at' - ) - ->groupBy('m.id', 'm.merchant_no', 'm.merchant_name', 'm.balance', 'm.email', 'm.status', 'm.remark', 'm.created_at') - ->orderByRaw('COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) DESC') - ->paginate($pageSize, ['*'], 'page', $page); - - return $this->success([ - 'summary' => [ - 'merchant_count' => (int)($summaryRow->merchant_count ?? 0), - 'settled_amount' => (string)($summaryRow->settled_amount ?? '0.00'), - 'pending_amount' => (string)($summaryRow->pending_amount ?? '0.00'), - 'fee_amount' => (string)($summaryRow->fee_amount ?? '0.00'), - 'net_amount' => (string)($summaryRow->net_amount ?? '0.00'), - 'notify_pending_orders' => (int)($summaryRow->notify_pending_orders ?? 0), - ], - 'list' => array_map(fn ($row) => (array)$row, $paginator->items()), - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'size' => $paginator->perPage(), - ]); - } - - public function audit(Request $request) - { - $page = (int)$request->get('page', 1); - $pageSize = (int)$request->get('page_size', 10); - $auditStatus = trim((string)$request->get('audit_status', '')); - $keyword = trim((string)$request->get('keyword', '')); - - $summaryQuery = Db::table('ma_mer as m'); - if ($keyword !== '') { - $summaryQuery->where(function ($query) use ($keyword) { - $query->where('m.merchant_no', 'like', '%' . $keyword . '%') - ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%'); - }); - } - if ($auditStatus === 'pending') { - $summaryQuery->where('m.status', 0); - } elseif ($auditStatus === 'approved') { - $summaryQuery->where('m.status', 1); - } - - $summaryRow = $summaryQuery - ->selectRaw( - 'COUNT(DISTINCT m.id) AS merchant_count, - COUNT(DISTINCT CASE WHEN m.status = 0 THEN m.id END) AS pending_count, - COUNT(DISTINCT CASE WHEN m.status = 1 THEN m.id END) AS approved_count' - ) - ->first(); - - $listQuery = Db::table('ma_mer as m') - ->leftJoin('ma_pay_app as ma', 'ma.mer_id', '=', 'm.id'); - if ($keyword !== '') { - $listQuery->where(function ($query) use ($keyword) { - $query->where('m.merchant_no', 'like', '%' . $keyword . '%') - ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%'); - }); - } - if ($auditStatus === 'pending') { - $listQuery->where('m.status', 0); - } elseif ($auditStatus === 'approved') { - $listQuery->where('m.status', 1); - } - - $paginator = $listQuery - ->selectRaw( - 'm.id, m.merchant_no, m.merchant_name, m.balance, m.email, m.status, m.remark, m.created_at, m.updated_at, - COUNT(DISTINCT ma.id) AS app_count, - COUNT(DISTINCT CASE WHEN ma.status = 1 THEN ma.id END) AS active_app_count, - COUNT(DISTINCT CASE WHEN ma.status = 0 THEN ma.id END) AS disabled_app_count' - ) - ->groupBy('m.id', 'm.merchant_no', 'm.merchant_name', 'm.balance', 'm.email', 'm.status', 'm.remark', 'm.created_at', 'm.updated_at') - ->orderBy('m.status', 'asc') - ->orderByDesc('m.id') - ->paginate($pageSize, ['*'], 'page', $page); - - $items = []; - foreach ($paginator->items() as $row) { - $item = (array)$row; - $item['audit_status'] = (int)($item['status'] ?? 0) === 1 ? 'approved' : 'pending'; - $item['audit_status_text'] = $item['audit_status'] === 'approved' ? 'approved' : 'pending'; - $items[] = $item; - } - - return $this->success([ - 'summary' => [ - 'merchant_count' => (int)($summaryRow->merchant_count ?? 0), - 'pending_count' => (int)($summaryRow->pending_count ?? 0), - 'approved_count' => (int)($summaryRow->approved_count ?? 0), - ], - 'list' => $items, - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'size' => $paginator->perPage(), - ]); - } - - public function auditAction(Request $request) - { - $id = (int)$request->post('id', 0); - $action = trim((string)$request->post('action', '')); - - if ($id <= 0 || !in_array($action, ['approve', 'suspend'], true)) { - return $this->fail('invalid params', 400); - } - - $status = $action === 'approve' ? 1 : 0; - Db::connection()->transaction(function () use ($id, $status) { - Db::table('ma_mer')->where('id', $id)->update([ - 'status' => $status, - 'updated_at' => date('Y-m-d H:i:s'), - ]); - Db::table('ma_pay_app')->where('mer_id', $id)->update([ - 'status' => $status, - 'updated_at' => date('Y-m-d H:i:s'), - ]); - }); - - return $this->success(null, $action === 'approve' ? 'approved' : 'suspended'); - } - - public function groupList(Request $request) - { - $page = max(1, (int)$request->get('page', 1)); - $pageSize = max(1, (int)$request->get('page_size', 10)); - $keyword = trim((string)$request->get('keyword', '')); - $status = $request->get('status', ''); - - $items = array_map([$this, 'normalizeGroupItem'], $this->getConfigEntries('merchant_groups')); - $items = array_values(array_filter($items, function (array $item) use ($keyword, $status) { - if ($keyword !== '') { - $haystacks = [ - strtolower((string)($item['group_code'] ?? '')), - strtolower((string)($item['group_name'] ?? '')), - strtolower((string)($item['remark'] ?? '')), - ]; - $needle = strtolower($keyword); - $matched = false; - foreach ($haystacks as $haystack) { - if ($haystack !== '' && str_contains($haystack, $needle)) { - $matched = true; - break; - } - } - if (!$matched) { - return false; - } - } - - if ($status !== '' && (int)$item['status'] !== (int)$status) { - return false; - } - - return true; - })); - - usort($items, function (array $a, array $b) { - $sortCompare = (int)($a['sort'] ?? 0) <=> (int)($b['sort'] ?? 0); - if ($sortCompare !== 0) { - return $sortCompare; - } - return strcmp((string)($b['updated_at'] ?? ''), (string)($a['updated_at'] ?? '')); - }); - - return $this->success($this->buildConfigPagePayload( - $items, - $page, - $pageSize, - [ - 'group_count' => count($items), - 'active_count' => count(array_filter($items, fn (array $item) => (int)$item['status'] === 1)), - 'disabled_count' => count(array_filter($items, fn (array $item) => (int)$item['status'] !== 1)), - ] - )); - } - - public function groupSave(Request $request) - { - $data = $request->post(); - $id = trim((string)($data['id'] ?? '')); - $groupCode = trim((string)($data['group_code'] ?? '')); - $groupName = trim((string)($data['group_name'] ?? '')); - - if ($groupCode === '' || $groupName === '') { - return $this->fail('group_code and group_name are required', 400); - } - - $items = array_map([$this, 'normalizeGroupItem'], $this->getConfigEntries('merchant_groups')); - foreach ($items as $item) { - if (($item['group_code'] ?? '') === $groupCode && ($item['id'] ?? '') !== $id) { - return $this->fail('group_code already exists', 400); - } - } - - $now = date('Y-m-d H:i:s'); - $saved = false; - foreach ($items as &$item) { - if (($item['id'] ?? '') !== $id || $id === '') { - continue; - } - - $item = array_merge($item, [ - 'group_code' => $groupCode, - 'group_name' => $groupName, - 'sort' => (int)($data['sort'] ?? 0), - 'status' => (int)($data['status'] ?? 1), - 'remark' => trim((string)($data['remark'] ?? '')), - 'updated_at' => $now, - ]); - $saved = true; - break; - } - unset($item); - - if (!$saved) { - $items[] = [ - 'id' => $id !== '' ? $id : uniqid('grp_', true), - 'group_code' => $groupCode, - 'group_name' => $groupName, - 'sort' => (int)($data['sort'] ?? 0), - 'status' => (int)($data['status'] ?? 1), - 'remark' => trim((string)($data['remark'] ?? '')), - 'merchant_count' => 0, - 'created_at' => $now, - 'updated_at' => $now, - ]; - } - - $this->setConfigEntries('merchant_groups', $items); - return $this->success(null, 'saved'); - } - - public function groupDelete(Request $request) - { - $id = trim((string)$request->post('id', '')); - if ($id === '') { - return $this->fail('id is required', 400); - } - - $items = array_map([$this, 'normalizeGroupItem'], $this->getConfigEntries('merchant_groups')); - $filtered = array_values(array_filter($items, fn (array $item) => ($item['id'] ?? '') !== $id)); - if (count($filtered) === count($items)) { - return $this->fail('group not found', 404); - } - - $this->setConfigEntries('merchant_groups', $filtered); - return $this->success(null, 'deleted'); - } - - public function packageList(Request $request) - { - $page = max(1, (int)$request->get('page', 1)); - $pageSize = max(1, (int)$request->get('page_size', 10)); - $keyword = trim((string)$request->get('keyword', '')); - $status = $request->get('status', ''); - $apiType = trim((string)$request->get('api_type', '')); - - $items = array_map([$this, 'normalizePackageItem'], $this->getConfigEntries('merchant_packages')); - $items = array_values(array_filter($items, function (array $item) use ($keyword, $status, $apiType) { - if ($keyword !== '') { - $haystacks = [ - strtolower((string)($item['package_code'] ?? '')), - strtolower((string)($item['package_name'] ?? '')), - strtolower((string)($item['fee_desc'] ?? '')), - strtolower((string)($item['remark'] ?? '')), - ]; - $needle = strtolower($keyword); - $matched = false; - foreach ($haystacks as $haystack) { - if ($haystack !== '' && str_contains($haystack, $needle)) { - $matched = true; - break; - } - } - if (!$matched) { - return false; - } - } - - if ($status !== '' && (int)$item['status'] !== (int)$status) { - return false; - } - if ($apiType !== '' && (string)$item['api_type'] !== $apiType) { - return false; - } - - return true; - })); - - usort($items, function (array $a, array $b) { - $sortCompare = (int)($a['sort'] ?? 0) <=> (int)($b['sort'] ?? 0); - if ($sortCompare !== 0) { - return $sortCompare; - } - return strcmp((string)($b['updated_at'] ?? ''), (string)($a['updated_at'] ?? '')); - }); - - $apiTypeCount = []; - foreach ($items as $item) { - $type = (string)($item['api_type'] ?? 'custom'); - $apiTypeCount[$type] = ($apiTypeCount[$type] ?? 0) + 1; - } - - return $this->success($this->buildConfigPagePayload( - $items, - $page, - $pageSize, - [ - 'package_count' => count($items), - 'active_count' => count(array_filter($items, fn (array $item) => (int)$item['status'] === 1)), - 'disabled_count' => count(array_filter($items, fn (array $item) => (int)$item['status'] !== 1)), - 'api_type_count' => $apiTypeCount, - ] - )); - } - - public function packageSave(Request $request) - { - $data = $request->post(); - $id = trim((string)($data['id'] ?? '')); - $packageCode = trim((string)($data['package_code'] ?? '')); - $packageName = trim((string)($data['package_name'] ?? '')); - $apiType = trim((string)($data['api_type'] ?? 'epay')); - - if ($packageCode === '' || $packageName === '') { - return $this->fail('package_code and package_name are required', 400); - } - if (!in_array($apiType, ['epay', 'openapi', 'custom'], true)) { - return $this->fail('invalid api_type', 400); - } - - $items = array_map([$this, 'normalizePackageItem'], $this->getConfigEntries('merchant_packages')); - foreach ($items as $item) { - if (($item['package_code'] ?? '') === $packageCode && ($item['id'] ?? '') !== $id) { - return $this->fail('package_code already exists', 400); - } - } - - $now = date('Y-m-d H:i:s'); - $saved = false; - foreach ($items as &$item) { - if (($item['id'] ?? '') !== $id || $id === '') { - continue; - } - - $item = array_merge($item, [ - 'package_code' => $packageCode, - 'package_name' => $packageName, - 'api_type' => $apiType, - 'sort' => (int)($data['sort'] ?? 0), - 'status' => (int)($data['status'] ?? 1), - 'channel_limit' => max(0, (int)($data['channel_limit'] ?? 0)), - 'daily_limit' => trim((string)($data['daily_limit'] ?? '')), - 'fee_desc' => trim((string)($data['fee_desc'] ?? '')), - 'callback_policy' => trim((string)($data['callback_policy'] ?? '')), - 'remark' => trim((string)($data['remark'] ?? '')), - 'updated_at' => $now, - ]); - $saved = true; - break; - } - unset($item); - - if (!$saved) { - $items[] = [ - 'id' => $id !== '' ? $id : uniqid('pkg_', true), - 'package_code' => $packageCode, - 'package_name' => $packageName, - 'api_type' => $apiType, - 'sort' => (int)($data['sort'] ?? 0), - 'status' => (int)($data['status'] ?? 1), - 'channel_limit' => max(0, (int)($data['channel_limit'] ?? 0)), - 'daily_limit' => trim((string)($data['daily_limit'] ?? '')), - 'fee_desc' => trim((string)($data['fee_desc'] ?? '')), - 'callback_policy' => trim((string)($data['callback_policy'] ?? '')), - 'remark' => trim((string)($data['remark'] ?? '')), - 'created_at' => $now, - 'updated_at' => $now, - ]; - } - - $this->setConfigEntries('merchant_packages', $items); - return $this->success(null, 'saved'); - } - - public function packageDelete(Request $request) - { - $id = trim((string)$request->post('id', '')); - if ($id === '') { - return $this->fail('id is required', 400); - } - - $items = array_map([$this, 'normalizePackageItem'], $this->getConfigEntries('merchant_packages')); - $filtered = array_values(array_filter($items, fn (array $item) => ($item['id'] ?? '') !== $id)); - if (count($filtered) === count($items)) { - return $this->fail('package not found', 404); - } - - $this->setConfigEntries('merchant_packages', $filtered); - return $this->success(null, 'deleted'); - } - - private function buildOpFilters(Request $request): array - { - return [ - 'merchant_id' => (int)$request->get('merchant_id', 0), - 'status' => (string)$request->get('status', ''), - 'keyword' => trim((string)$request->get('keyword', '')), - 'email' => trim((string)$request->get('email', '')), - 'balance' => trim((string)$request->get('balance', '')), - 'created_from' => trim((string)$request->get('created_from', '')), - 'created_to' => trim((string)$request->get('created_to', '')), - ]; - } - - private function applyMerchantFilters($query, array $filters): void - { - if (($filters['status'] ?? '') !== '') { - $query->where('m.status', (int)$filters['status']); - } - if (!empty($filters['merchant_id'])) { - $query->where('m.id', (int)$filters['merchant_id']); - } - if (!empty($filters['keyword'])) { - $query->where(function ($builder) use ($filters) { - $builder->where('m.merchant_no', 'like', '%' . $filters['keyword'] . '%') - ->orWhere('m.merchant_name', 'like', '%' . $filters['keyword'] . '%') - ->orWhere('m.email', 'like', '%' . $filters['keyword'] . '%'); - }); - } - if (!empty($filters['email'])) { - $query->where('m.email', 'like', '%' . $filters['email'] . '%'); - } - if (isset($filters['balance']) && $filters['balance'] !== '') { - $query->where('m.balance', (string)$filters['balance']); - } - } - - private function getConfigEntries(string $configKey): array - { - $raw = $this->systemConfigService->getValue($configKey, '[]'); - if (!is_string($raw) || $raw === '') { - return []; - } - - $decoded = json_decode($raw, true); - if (!is_array($decoded)) { - return []; - } - - return array_values(array_filter($decoded, 'is_array')); - } - - private function setConfigEntries(string $configKey, array $items): void - { - $this->systemConfigService->setValue($configKey, array_values($items)); - } - - private function buildConfigPagePayload(array $items, int $page, int $pageSize, array $summary): array - { - $offset = ($page - 1) * $pageSize; - return [ - 'summary' => $summary, - 'list' => array_values(array_slice($items, $offset, $pageSize)), - 'total' => count($items), - 'page' => $page, - 'size' => $pageSize, - ]; - } - - private function normalizeGroupItem(array $item): array - { - return [ - 'id' => (string)($item['id'] ?? ''), - 'group_code' => trim((string)($item['group_code'] ?? '')), - 'group_name' => trim((string)($item['group_name'] ?? '')), - 'sort' => (int)($item['sort'] ?? 0), - 'status' => (int)($item['status'] ?? 1), - 'remark' => trim((string)($item['remark'] ?? '')), - 'merchant_count' => max(0, (int)($item['merchant_count'] ?? 0)), - 'created_at' => (string)($item['created_at'] ?? ''), - 'updated_at' => (string)($item['updated_at'] ?? ''), - ]; - } - - private function normalizePackageItem(array $item): array - { - $apiType = trim((string)($item['api_type'] ?? 'epay')); - if (!in_array($apiType, ['epay', 'openapi', 'custom'], true)) { - $apiType = 'custom'; - } - - return [ - 'id' => (string)($item['id'] ?? ''), - 'package_code' => trim((string)($item['package_code'] ?? '')), - 'package_name' => trim((string)($item['package_name'] ?? '')), - 'api_type' => $apiType, - 'sort' => (int)($item['sort'] ?? 0), - 'status' => (int)($item['status'] ?? 1), - 'channel_limit' => max(0, (int)($item['channel_limit'] ?? 0)), - 'daily_limit' => trim((string)($item['daily_limit'] ?? '')), - 'fee_desc' => trim((string)($item['fee_desc'] ?? '')), - 'callback_policy' => trim((string)($item['callback_policy'] ?? '')), - 'remark' => trim((string)($item['remark'] ?? '')), - 'created_at' => (string)($item['created_at'] ?? ''), - 'updated_at' => (string)($item['updated_at'] ?? ''), - ]; - } - - private function merchantProfileKey(int $merchantId): string - { - return 'merchant_profile_' . $merchantId; - } - - private function defaultMerchantProfile(): array - { - return [ - 'group_code' => '', - 'contact_name' => '', - 'contact_phone' => '', - 'notify_email' => '', - 'callback_domain' => '', - 'callback_ip_whitelist' => '', - 'risk_level' => 'standard', - 'single_limit' => 0, - 'daily_limit' => 0, - 'settlement_cycle' => 't1', - 'tech_support' => '', - 'remark' => '', - 'updated_at' => '', - ]; - } - - private function buildGroupMap(): array - { - $map = []; - foreach ($this->getConfigEntries('merchant_groups') as $group) { - $groupCode = trim((string)($group['group_code'] ?? '')); - if ($groupCode === '') { - continue; - } - $map[$groupCode] = trim((string)($group['group_name'] ?? $groupCode)); - } - - return $map; - } - - private function normalizeMerchantRow(array $merchant): array - { - $merchant['merchant_no'] = trim((string)($merchant['merchant_no'] ?? '')); - $merchant['merchant_name'] = trim((string)($merchant['merchant_name'] ?? '')); - $merchant['balance'] = (string)($merchant['balance'] ?? '0.00'); - $merchant['email'] = trim((string)($merchant['email'] ?? '')); - $merchant['remark'] = trim((string)($merchant['remark'] ?? '')); - $merchant['status'] = (int)($merchant['status'] ?? 1); - $merchant['created_at'] = (string)($merchant['created_at'] ?? ''); - $merchant['updated_at'] = (string)($merchant['updated_at'] ?? ''); - return $merchant; - } - - private function buildMerchantProfile(array $merchant): array - { - return [ - 'merchant_no' => trim((string)($merchant['merchant_no'] ?? '')), - 'merchant_name' => trim((string)($merchant['merchant_name'] ?? '')), - 'balance' => (string)($merchant['balance'] ?? '0.00'), - 'email' => trim((string)($merchant['email'] ?? '')), - 'status' => (int)($merchant['status'] ?? 1), - 'remark' => trim((string)($merchant['remark'] ?? '')), - ]; - } - - private function getConfigObject(string $configKey): array - { - $raw = $this->systemConfigService->getValue($configKey, '{}'); - if (!is_string($raw) || $raw === '') { - return []; - } - - $decoded = json_decode($raw, true); - return is_array($decoded) ? $decoded : []; - } -} diff --git a/app/http/admin/controller/OrderController.php b/app/http/admin/controller/OrderController.php deleted file mode 100644 index 91fdb24..0000000 --- a/app/http/admin/controller/OrderController.php +++ /dev/null @@ -1,316 +0,0 @@ -get('page', 1); - $pageSize = (int)$request->get('page_size', 10); - $filters = $this->buildListFilters($request); - - $paginator = $this->orderRepository->searchPaginate($filters, $page, $pageSize); - $items = []; - foreach ($paginator->items() as $row) { - $items[] = $this->formatOrderRow($row); - } - - return $this->success([ - 'list' => $items, - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'size' => $paginator->perPage(), - ]); - } - - /** - * GET /adminapi/order/detail?id=1 或 order_id=P... - */ - public function detail(Request $request) - { - $id = (int)$request->get('id', 0); - $orderId = trim((string)$request->get('order_id', '')); - - if ($id > 0) { - $row = $this->orderRepository->find($id); - } elseif ($orderId !== '') { - $row = $this->orderRepository->findByOrderId($orderId); - } else { - return $this->fail('参数错误', 400); - } - - if (!$row) { - return $this->fail('订单不存在', 404); - } - - return $this->success($this->formatOrderRow($row)); - } - - /** - * GET /adminapi/order/export - */ - public function export(Request $request): Response - { - $limit = 5000; - $filters = $this->buildListFilters($request); - $rows = $this->orderRepository->searchList($filters, $limit); - - $merchantIds = []; - $merchantAppIds = []; - $methodIds = []; - $channelIds = []; - $items = []; - - foreach ($rows as $row) { - $item = $this->formatOrderRow($row); - $items[] = $item; - if (!empty($item['merchant_id'])) { - $merchantIds[] = (int)$item['merchant_id']; - } - if (!empty($item['merchant_app_id'])) { - $merchantAppIds[] = (int)$item['merchant_app_id']; - } - if (!empty($item['method_id'])) { - $methodIds[] = (int)$item['method_id']; - } - if (!empty($item['channel_id'])) { - $channelIds[] = (int)$item['channel_id']; - } - } - - $merchantMap = Merchant::query() - ->whereIn('id', array_values(array_unique($merchantIds))) - ->get(['id', 'merchant_no', 'merchant_name']) - ->keyBy('id'); - $merchantAppMap = MerchantApp::query() - ->whereIn('id', array_values(array_unique($merchantAppIds))) - ->get(['id', 'app_id', 'app_name']) - ->keyBy('id'); - $methodMap = PaymentMethod::query() - ->whereIn('id', array_values(array_unique($methodIds))) - ->get(['id', 'method_code', 'method_name']) - ->keyBy('id'); - $channelMap = PaymentChannel::query() - ->whereIn('id', array_values(array_unique($channelIds))) - ->get(['id', 'chan_code', 'chan_name']) - ->keyBy('id'); - - $stream = fopen('php://temp', 'r+'); - fwrite($stream, "\xEF\xBB\xBF"); - fputcsv($stream, [ - '系统单号', - '商户单号', - '商户编号', - '商户名称', - '应用APPID', - '应用名称', - '支付方式编码', - '支付方式名称', - '通道编码', - '通道名称', - '订单金额', - '实收金额', - '手续费', - '币种', - '订单状态', - '路由结果', - '路由模式', - '策略名称', - '通道单号', - '通道交易号', - '通知状态', - '通知次数', - '客户端IP', - '商品标题', - '创建时间', - '支付时间', - '路由错误', - ]); - - foreach ($items as $item) { - $merchant = $merchantMap->get((int)($item['merchant_id'] ?? 0)); - $merchantApp = $merchantAppMap->get((int)($item['merchant_app_id'] ?? 0)); - $method = $methodMap->get((int)($item['method_id'] ?? 0)); - $channel = $channelMap->get((int)($item['channel_id'] ?? 0)); - - fputcsv($stream, [ - (string)($item['order_id'] ?? ''), - (string)($item['mch_order_no'] ?? ''), - (string)($merchant->merchant_no ?? ''), - (string)($merchant->merchant_name ?? ''), - (string)($merchantApp->app_id ?? ''), - (string)($merchantApp->app_name ?? ''), - (string)($method->method_code ?? ''), - (string)($method->method_name ?? ''), - (string)($channel->chan_code ?? $item['route_channel_code'] ?? ''), - (string)($channel->chan_name ?? $item['route_channel_name'] ?? ''), - (string)($item['amount'] ?? '0.00'), - (string)($item['real_amount'] ?? '0.00'), - (string)($item['fee'] ?? '0.00'), - (string)($item['currency'] ?? ''), - $this->statusText((int)($item['status'] ?? 0)), - (string)($item['route_source_text'] ?? ''), - (string)($item['route_mode_text'] ?? ''), - (string)($item['route_policy_name'] ?? ''), - (string)($item['chan_order_no'] ?? ''), - (string)($item['chan_trade_no'] ?? ''), - $this->notifyStatusText((int)($item['notify_stat'] ?? 0)), - (string)($item['notify_cnt'] ?? '0'), - (string)($item['client_ip'] ?? ''), - (string)($item['subject'] ?? ''), - (string)($item['created_at'] ?? ''), - (string)($item['pay_at'] ?? ''), - (string)($item['route_error']['message'] ?? ''), - ]); - } - - rewind($stream); - $content = stream_get_contents($stream) ?: ''; - fclose($stream); - - $filename = 'orders-' . date('Ymd-His') . '.csv'; - return response($content, 200, [ - 'Content-Type' => 'text/csv; charset=UTF-8', - 'Content-Disposition' => "attachment; filename*=UTF-8''" . rawurlencode($filename), - 'X-Export-Count' => (string)count($items), - 'X-Export-Limit' => (string)$limit, - 'X-Export-Limited' => count($items) >= $limit ? '1' : '0', - ]); - } - - /** - * POST /adminapi/order/refund - * - order_id: 系统订单号 - * - refund_amount: 退款金额 - */ - public function refund(Request $request) - { - $orderId = trim((string)$request->post('order_id', '')); - $refundAmount = (float)$request->post('refund_amount', 0); - $refundReason = trim((string)$request->post('refund_reason', '')); - - try { - $result = $this->payOrderService->refundOrder([ - 'order_id' => $orderId, - 'refund_amount' => $refundAmount, - 'refund_reason' => $refundReason, - ]); - return $this->success($result, '退款发起成功'); - } catch (\Throwable $e) { - return $this->fail($e->getMessage(), 400); - } - } - - private function formatOrderRow(object $row): array - { - $data = method_exists($row, 'toArray') ? $row->toArray() : (array)$row; - $extra = is_array($data['extra'] ?? null) ? $data['extra'] : []; - $routing = is_array($extra['routing'] ?? null) ? $extra['routing'] : null; - $routeError = is_array($extra['route_error'] ?? null) ? $extra['route_error'] : null; - - $data['routing'] = $routing; - $data['route_error'] = $routeError; - $data['route_candidates'] = is_array($routing['candidates'] ?? null) ? $routing['candidates'] : []; - $data['route_policy_name'] = (string)($routing['policy']['policy_name'] ?? ''); - $data['route_source'] = (string)($routing['source'] ?? ''); - $data['route_source_text'] = $this->routeSourceText($routing, $routeError); - $data['route_mode_text'] = $this->routeModeText((string)($routing['route_mode'] ?? '')); - $data['route_channel_name'] = (string)($routing['selected_channel_name'] ?? ''); - $data['route_channel_code'] = (string)($routing['selected_channel_code'] ?? ''); - $data['route_state'] = $routeError - ? 'error' - : ($routing ? (string)($routing['source'] ?? 'unknown') : 'none'); - - return $data; - } - - private function buildListFilters(Request $request): array - { - $methodCode = trim((string)$request->get('method_code', '')); - $methodId = 0; - if ($methodCode !== '') { - $method = $this->methodRepository->findAnyByCode($methodCode); - $methodId = $method ? (int)$method->id : 0; - } - - return [ - 'merchant_id' => (int)$request->get('merchant_id', 0), - 'merchant_app_id' => (int)$request->get('merchant_app_id', 0), - 'method_id' => $methodId, - 'channel_id' => (int)$request->get('channel_id', 0), - 'route_state' => trim((string)$request->get('route_state', '')), - 'route_policy_name' => trim((string)$request->get('route_policy_name', '')), - 'status' => $request->get('status', ''), - 'order_id' => trim((string)$request->get('order_id', '')), - 'mch_order_no' => trim((string)$request->get('mch_order_no', '')), - 'created_from' => trim((string)$request->get('created_from', '')), - 'created_to' => trim((string)$request->get('created_to', '')), - ]; - } - - private function statusText(int $status): string - { - return match ($status) { - 0 => '待支付', - 1 => '成功', - 2 => '失败', - 3 => '关闭', - default => (string)$status, - }; - } - - private function notifyStatusText(int $notifyStatus): string - { - return $notifyStatus === 1 ? '已通知' : '待通知'; - } - - private function routeSourceText(?array $routing, ?array $routeError): string - { - if ($routeError) { - return '路由失败'; - } - - return match ((string)($routing['source'] ?? '')) { - 'policy' => '策略命中', - 'fallback' => '回退选择', - default => '未记录', - }; - } - - private function routeModeText(string $routeMode): string - { - return match ($routeMode) { - 'priority' => '优先级', - 'weight' => '权重分流', - 'failover' => '主备切换', - 'sort' => '排序兜底', - default => $routeMode ?: '-', - }; - } -} - diff --git a/app/http/admin/controller/PayMethodController.php b/app/http/admin/controller/PayMethodController.php deleted file mode 100644 index 4b4f8e5..0000000 --- a/app/http/admin/controller/PayMethodController.php +++ /dev/null @@ -1,96 +0,0 @@ -get('page', 1); - $pageSize = (int)$request->get('page_size', 10); - - $filters = [ - 'status' => $request->get('status', ''), - 'method_code' => trim((string)$request->get('method_code', '')), - 'method_name' => trim((string)$request->get('method_name', '')), - ]; - - $paginator = $this->methodRepository->searchPaginate($filters, $page, $pageSize); - return $this->page($paginator); - } - - /** - * POST /adminapi/pay-method/save - */ - public function save(Request $request) - { - $data = $request->post(); - $id = (int)($data['id'] ?? 0); - - $code = trim((string)($data['method_code'] ?? '')); - $name = trim((string)($data['method_name'] ?? '')); - $icon = trim((string)($data['icon'] ?? '')); - $sort = (int)($data['sort'] ?? 0); - $status = (int)($data['status'] ?? 1); - - if ($code === '' || $name === '') { - return $this->fail('支付方式编码与名称不能为空', 400); - } - - if ($id > 0) { - $this->methodRepository->updateById($id, [ - 'type' => $code, - 'name' => $name, - 'icon' => $icon, - 'sort' => $sort, - 'status' => $status, - ]); - } else { - $exists = $this->methodRepository->findAnyByCode($code); - if ($exists) { - return $this->fail('支付方式编码已存在', 400); - } - $this->methodRepository->create([ - 'type' => $code, - 'name' => $name, - 'icon' => $icon, - 'sort' => $sort, - 'status' => $status, - ]); - } - - return $this->success(null, '保存成功'); - } - - /** - * POST /adminapi/pay-method/toggle - */ - public function toggle(Request $request) - { - $id = (int)$request->post('id', 0); - $status = $request->post('status', null); - - if ($id <= 0 || $status === null) { - return $this->fail('参数错误', 400); - } - - $ok = $this->methodRepository->updateById($id, ['status' => (int)$status]); - return $ok ? $this->success(null, '操作成功') : $this->fail('操作失败', 500); - } -} - diff --git a/app/http/admin/controller/PayPluginController.php b/app/http/admin/controller/PayPluginController.php deleted file mode 100644 index 01c1168..0000000 --- a/app/http/admin/controller/PayPluginController.php +++ /dev/null @@ -1,85 +0,0 @@ -get('page', 1); - $pageSize = (int)$request->get('page_size', 10); - - $filters = [ - 'status' => $request->get('status', ''), - 'plugin_code' => trim((string)$request->get('plugin_code', '')), - 'plugin_name' => trim((string)$request->get('plugin_name', '')), - ]; - - $paginator = $this->pluginRepository->searchPaginate($filters, $page, $pageSize); - return $this->page($paginator); - } - - /** - * POST /adminapi/pay-plugin/save - */ - public function save(Request $request) - { - $data = $request->post(); - - $pluginCode = trim((string)($data['plugin_code'] ?? '')); - $pluginName = trim((string)($data['plugin_name'] ?? '')); - $className = trim((string)($data['class_name'] ?? '')); - $status = (int)($data['status'] ?? 1); - - if ($pluginCode === '' || $pluginName === '') { - return $this->fail('插件编码与名称不能为空', 400); - } - - if ($className === '') { - // 默认约定类名 - $className = ucfirst($pluginCode) . 'Payment'; - } - - $this->pluginRepository->upsertByCode($pluginCode, [ - 'name' => $pluginName, - 'class_name' => $className, - 'status' => $status, - ]); - - return $this->success(null, '保存成功'); - } - - /** - * POST /adminapi/pay-plugin/toggle - */ - public function toggle(Request $request) - { - $pluginCode = trim((string)$request->post('plugin_code', '')); - $status = $request->post('status', null); - - if ($pluginCode === '' || $status === null) { - return $this->fail('参数错误', 400); - } - - $ok = $this->pluginRepository->updateStatus($pluginCode, (int)$status); - return $ok ? $this->success(null, '操作成功') : $this->fail('操作失败', 500); - } -} - diff --git a/app/http/admin/controller/PluginController.php b/app/http/admin/controller/PluginController.php deleted file mode 100644 index 6a6d3f7..0000000 --- a/app/http/admin/controller/PluginController.php +++ /dev/null @@ -1,71 +0,0 @@ -pluginService->listPlugins(); - return $this->success($plugins); - } - - /** - * 获取插件配置Schema - * GET /adminapi/channel/plugin/config-schema - */ - public function configSchema(Request $request) - { - $pluginCode = $request->get('plugin_code', ''); - $methodCode = $request->get('method_code', ''); - - if (empty($pluginCode) || empty($methodCode)) { - return $this->fail('插件编码和支付方式不能为空', 400); - } - - try { - $schema = $this->pluginService->getConfigSchema($pluginCode, $methodCode); - return $this->success($schema); - } catch (\Throwable $e) { - return $this->fail('获取配置Schema失败:' . $e->getMessage(), 400); - } - } - - /** - * 获取插件支持的支付产品列表 - * GET /adminapi/channel/plugin/products - */ - public function products(Request $request) - { - $pluginCode = $request->get('plugin_code', ''); - $methodCode = $request->get('method_code', ''); - - if (empty($pluginCode) || empty($methodCode)) { - return $this->fail('插件编码和支付方式不能为空', 400); - } - - try { - $products = $this->pluginService->getSupportedProducts($pluginCode, $methodCode); - return $this->success($products); - } catch (\Throwable $e) { - return $this->fail('获取产品列表失败:' . $e->getMessage(), 400); - } - } -} - diff --git a/app/http/admin/controller/SystemController.php b/app/http/admin/controller/SystemController.php deleted file mode 100644 index 96a0f23..0000000 --- a/app/http/admin/controller/SystemController.php +++ /dev/null @@ -1,312 +0,0 @@ -settingService->getDict($code); - return $this->success($data); - } - - public function getTabsConfig() - { - return $this->success($this->settingService->getTabs()); - } - - public function getFormConfig(Request $request, string $tabKey) - { - return $this->success($this->settingService->getFormConfig($tabKey)); - } - - public function submitConfig(Request $request, string $tabKey) - { - $formData = $request->post(); - if (empty($formData)) { - return $this->fail('submitted data is empty', 400); - } - - $this->settingService->saveFormConfig($tabKey, $formData); - return $this->success(null, 'saved'); - } - - public function logFiles() - { - $logDir = runtime_path('logs'); - if (!is_dir($logDir)) { - return $this->success([]); - } - - $items = []; - foreach (glob($logDir . DIRECTORY_SEPARATOR . '*.log') ?: [] as $file) { - if (!is_file($file)) { - continue; - } - - $items[] = [ - 'name' => basename($file), - 'size' => filesize($file) ?: 0, - 'updated_at' => date('Y-m-d H:i:s', filemtime($file) ?: time()), - ]; - } - - usort($items, fn ($a, $b) => strcmp((string)$b['updated_at'], (string)$a['updated_at'])); - - return $this->success($items); - } - - public function logSummary() - { - $logDir = runtime_path('logs'); - if (!is_dir($logDir)) { - return $this->success([ - 'total_files' => 0, - 'total_size' => 0, - 'latest_file' => '', - 'categories' => [], - ]); - } - - $files = glob($logDir . DIRECTORY_SEPARATOR . '*.log') ?: []; - $categoryStats = []; - $totalSize = 0; - $latestFile = ''; - $latestTime = 0; - - foreach ($files as $file) { - if (!is_file($file)) { - continue; - } - - $size = filesize($file) ?: 0; - $updatedAt = filemtime($file) ?: 0; - $name = basename($file); - $category = $this->resolveLogCategory($name); - - $totalSize += $size; - if (!isset($categoryStats[$category])) { - $categoryStats[$category] = [ - 'category' => $category, - 'file_count' => 0, - 'total_size' => 0, - ]; - } - - $categoryStats[$category]['file_count']++; - $categoryStats[$category]['total_size'] += $size; - - if ($updatedAt >= $latestTime) { - $latestTime = $updatedAt; - $latestFile = $name; - } - } - - return $this->success([ - 'total_files' => count($files), - 'total_size' => $totalSize, - 'latest_file' => $latestFile, - 'categories' => array_values($categoryStats), - ]); - } - - public function logContent(Request $request) - { - $file = basename(trim((string)$request->get('file', ''))); - $lines = max(20, min(1000, (int)$request->get('lines', 200))); - $keyword = trim((string)$request->get('keyword', '')); - $level = strtoupper(trim((string)$request->get('level', ''))); - if ($file === '') { - return $this->fail('file is required', 400); - } - - $logDir = runtime_path('logs'); - $fullPath = realpath($logDir . DIRECTORY_SEPARATOR . $file); - $realLogDir = realpath($logDir); - - if (!$fullPath || !$realLogDir || !str_starts_with($fullPath, $realLogDir) || !is_file($fullPath)) { - return $this->fail('log file not found', 404); - } - - $contentLines = file($fullPath, FILE_IGNORE_NEW_LINES); - if (!is_array($contentLines)) { - return $this->fail('failed to read log file', 500); - } - - if ($keyword !== '') { - $contentLines = array_values(array_filter($contentLines, static function ($line) use ($keyword) { - return stripos($line, $keyword) !== false; - })); - } - - if ($level !== '') { - $contentLines = array_values(array_filter($contentLines, static function ($line) use ($level) { - return stripos(strtoupper($line), $level) !== false; - })); - } - - $matchedLineCount = count($contentLines); - $tail = array_slice($contentLines, -$lines); - return $this->success([ - 'file' => $file, - 'size' => filesize($fullPath) ?: 0, - 'updated_at' => date('Y-m-d H:i:s', filemtime($fullPath) ?: time()), - 'line_count' => $matchedLineCount, - 'keyword' => $keyword, - 'level' => $level, - 'lines' => $tail, - 'content' => implode(PHP_EOL, $tail), - ]); - } - - public function noticeOverview() - { - $config = $this->configService->getValues([ - 'smtp_host', - 'smtp_port', - 'smtp_ssl', - 'smtp_username', - 'smtp_password', - 'from_email', - 'from_name', - ]); - - $taskSummary = Db::table('ma_notify_task') - ->selectRaw( - 'COUNT(*) AS total_tasks, - SUM(CASE WHEN status = \'PENDING\' THEN 1 ELSE 0 END) AS pending_tasks, - SUM(CASE WHEN status = \'SUCCESS\' THEN 1 ELSE 0 END) AS success_tasks, - SUM(CASE WHEN status = \'FAIL\' THEN 1 ELSE 0 END) AS fail_tasks, - MAX(last_notify_at) AS last_notify_at' - ) - ->first(); - - $orderSummary = Db::table('ma_pay_order') - ->selectRaw( - 'SUM(CASE WHEN status = 1 AND notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders, - SUM(CASE WHEN status = 1 AND notify_stat = 1 THEN 1 ELSE 0 END) AS notified_orders' - ) - ->first(); - - $requiredKeys = ['smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'from_email']; - $configuredCount = 0; - foreach ($requiredKeys as $key) { - if (!empty($config[$key])) { - $configuredCount++; - } - } - - return $this->success([ - 'config' => [ - 'smtp_host' => (string)($config['smtp_host'] ?? ''), - 'smtp_port' => (string)($config['smtp_port'] ?? ''), - 'smtp_ssl' => in_array(strtolower((string)($config['smtp_ssl'] ?? '')), ['1', 'true', 'yes', 'on'], true), - 'smtp_username' => $this->maskString((string)($config['smtp_username'] ?? '')), - 'from_email' => (string)($config['from_email'] ?? ''), - 'from_name' => (string)($config['from_name'] ?? ''), - 'configured_fields' => $configuredCount, - 'required_fields' => count($requiredKeys), - 'is_ready' => $configuredCount === count($requiredKeys), - ], - 'tasks' => [ - 'total_tasks' => (int)($taskSummary->total_tasks ?? 0), - 'pending_tasks' => (int)($taskSummary->pending_tasks ?? 0), - 'success_tasks' => (int)($taskSummary->success_tasks ?? 0), - 'fail_tasks' => (int)($taskSummary->fail_tasks ?? 0), - 'last_notify_at' => (string)($taskSummary->last_notify_at ?? ''), - ], - 'orders' => [ - 'notify_pending_orders' => (int)($orderSummary->notify_pending_orders ?? 0), - 'notified_orders' => (int)($orderSummary->notified_orders ?? 0), - ], - ]); - } - - public function noticeTest(Request $request) - { - $config = $this->configService->getValues([ - 'smtp_host', - 'smtp_port', - 'smtp_ssl', - 'smtp_username', - 'smtp_password', - 'from_email', - ]); - - $missingFields = []; - foreach (['smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'from_email'] as $field) { - if (empty($config[$field])) { - $missingFields[] = $field; - } - } - - if ($missingFields !== []) { - return $this->fail('missing config fields: ' . implode(', ', $missingFields), 400); - } - - $host = (string)$config['smtp_host']; - $port = (int)$config['smtp_port']; - $useSsl = in_array(strtolower((string)($config['smtp_ssl'] ?? '')), ['1', 'true', 'yes', 'on'], true); - $transport = ($useSsl ? 'ssl://' : 'tcp://') . $host . ':' . $port; - - $errno = 0; - $errstr = ''; - $connection = @stream_socket_client($transport, $errno, $errstr, 5, STREAM_CLIENT_CONNECT); - - if (!is_resource($connection)) { - return $this->fail('smtp connection failed: ' . ($errstr !== '' ? $errstr : 'unknown error'), 500); - } - - stream_set_timeout($connection, 3); - $banner = fgets($connection, 512) ?: ''; - fclose($connection); - - return $this->success([ - 'transport' => $transport, - 'banner' => trim($banner), - 'checked_at' => date('Y-m-d H:i:s'), - 'note' => 'only smtp connectivity and basic config were verified; no test email was sent', - ], 'smtp connection ok'); - } - - private function resolveLogCategory(string $fileName): string - { - $name = strtolower($fileName); - if (str_contains($name, 'pay') || str_contains($name, 'notify')) { - return 'payment'; - } - if (str_contains($name, 'queue') || str_contains($name, 'job')) { - return 'queue'; - } - if (str_contains($name, 'error') || str_contains($name, 'exception')) { - return 'error'; - } - if (str_contains($name, 'admin') || str_contains($name, 'system')) { - return 'system'; - } - - return 'other'; - } - - private function maskString(string $value): string - { - $length = strlen($value); - if ($length <= 4) { - return $value === '' ? '' : str_repeat('*', $length); - } - - return substr($value, 0, 2) . str_repeat('*', max(2, $length - 4)) . substr($value, -2); - } -} diff --git a/app/http/admin/controller/account/MerchantAccountController.php b/app/http/admin/controller/account/MerchantAccountController.php new file mode 100644 index 0000000..902a3f3 --- /dev/null +++ b/app/http/admin/controller/account/MerchantAccountController.php @@ -0,0 +1,63 @@ +validated($request->all(), MerchantAccountValidator::class, 'index'); + + return $this->page( + $this->merchantAccountService->paginate( + $data, + (int) ($data['page'] ?? 1), + (int) ($data['page_size'] ?? 10) + ) + ); + } + + /** + * 资金中心概览。 + */ + public function summary(Request $request): Response + { + return $this->success($this->merchantAccountService->summary()); + } + + /** + * 查询商户账户详情。 + */ + public function show(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], MerchantAccountValidator::class, 'show'); + $account = $this->merchantAccountService->findById((int) $data['id']); + + if (!$account) { + return $this->fail('商户账户不存在', 404); + } + + return $this->success($account); + } +} + diff --git a/app/http/admin/controller/account/MerchantAccountLedgerController.php b/app/http/admin/controller/account/MerchantAccountLedgerController.php new file mode 100644 index 0000000..edb6032 --- /dev/null +++ b/app/http/admin/controller/account/MerchantAccountLedgerController.php @@ -0,0 +1,54 @@ +validated($request->all(), MerchantAccountLedgerValidator::class, 'index'); + + return $this->page( + $this->merchantAccountLedgerService->paginate( + $data, + (int) ($data['page'] ?? 1), + (int) ($data['page_size'] ?? 10) + ) + ); + } + + /** + * 查询账户流水详情。 + */ + public function show(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], MerchantAccountLedgerValidator::class, 'show'); + $ledger = $this->merchantAccountLedgerService->findById((int) $data['id']); + + if (!$ledger) { + return $this->fail('账户流水不存在', 404); + } + + return $this->success($ledger); + } +} diff --git a/app/http/admin/controller/file/FileRecordController.php b/app/http/admin/controller/file/FileRecordController.php new file mode 100644 index 0000000..fcb653b --- /dev/null +++ b/app/http/admin/controller/file/FileRecordController.php @@ -0,0 +1,118 @@ +validated($request->all(), FileRecordValidator::class, 'index'); + + return $this->page( + $this->fileRecordService->paginate( + $data, + (int) ($data['page'] ?? 1), + (int) ($data['page_size'] ?? 10) + ) + ); + } + + public function options(Request $request): Response + { + return $this->success($this->fileRecordService->options()); + } + + public function show(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], FileRecordValidator::class, 'show'); + + return $this->success($this->fileRecordService->detail((int) $data['id'])); + } + + public function upload(Request $request): Response + { + $data = $this->validated(array_merge($this->payload($request), ['scene' => $request->input('scene')]), FileRecordValidator::class, 'store'); + $uploadedFile = $request->file('file'); + if ($uploadedFile === null) { + return $this->fail('请先选择上传文件', 400); + } + + $createdBy = $this->currentAdminId($request); + $createdByName = (string) $this->requestAttribute($request, 'auth.admin_username', ''); + + if (is_array($uploadedFile)) { + $items = []; + foreach ($uploadedFile as $file) { + if ($file instanceof UploadFile) { + $items[] = $this->fileRecordService->upload($file, $data, $createdBy, $createdByName); + } + } + + return $this->success([ + 'list' => $items, + 'total' => count($items), + ]); + } + + if (!$uploadedFile instanceof UploadFile) { + return $this->fail('上传文件无效', 400); + } + + return $this->success($this->fileRecordService->upload($uploadedFile, $data, $createdBy, $createdByName)); + } + + public function importRemote(Request $request): Response + { + $data = $this->validated($this->payload($request), FileRecordValidator::class, 'importRemote'); + $createdBy = $this->currentAdminId($request); + $createdByName = (string) $this->requestAttribute($request, 'auth.admin_username', ''); + + return $this->success( + $this->fileRecordService->importRemote( + (string) $data['remote_url'], + $data, + $createdBy, + $createdByName + ) + ); + } + + public function preview(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], FileRecordValidator::class, 'preview'); + + return $this->fileRecordService->previewResponse((int) $data['id']); + } + + public function download(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], FileRecordValidator::class, 'download'); + + return $this->fileRecordService->downloadResponse((int) $data['id']); + } + + public function destroy(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], FileRecordValidator::class, 'destroy'); + if (!$this->fileRecordService->delete((int) $data['id'])) { + return $this->fail('文件不存在', 404); + } + + return $this->success(true); + } +} diff --git a/app/http/admin/controller/merchant/MerchantApiCredentialController.php b/app/http/admin/controller/merchant/MerchantApiCredentialController.php new file mode 100644 index 0000000..6e4e44c --- /dev/null +++ b/app/http/admin/controller/merchant/MerchantApiCredentialController.php @@ -0,0 +1,103 @@ +validated($request->all(), MerchantApiCredentialValidator::class, 'index'); + + return $this->page( + $this->merchantApiCredentialService->paginate( + $data, + (int) ($data['page'] ?? 1), + (int) ($data['page_size'] ?? 10) + ) + ); + } + + /** + * 查询商户 API 凭证详情。 + */ + public function show(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], MerchantApiCredentialValidator::class, 'show'); + $credential = $this->merchantApiCredentialService->findById((int) $data['id']); + + if (!$credential) { + return $this->fail('商户接口凭证不存在', 404); + } + + return $this->success($credential); + } + + /** + * 新增商户 API 凭证。 + */ + public function store(Request $request): Response + { + $data = $this->validated($request->all(), MerchantApiCredentialValidator::class, 'store'); + + return $this->success($this->merchantApiCredentialService->create($data)); + } + + /** + * 修改商户 API 凭证。 + */ + public function update(Request $request, string $id): Response + { + $data = $this->validated( + array_merge($request->all(), ['id' => (int) $id]), + MerchantApiCredentialValidator::class, + 'update' + ); + + $credential = $this->merchantApiCredentialService->update((int) $data['id'], $data); + if (!$credential) { + return $this->fail('商户接口凭证不存在', 404); + } + + return $this->success($credential); + } + + /** + * 删除商户 API 凭证。 + */ + public function destroy(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], MerchantApiCredentialValidator::class, 'destroy'); + $credential = $this->merchantApiCredentialService->findById((int) $data['id']); + + if (!$credential) { + return $this->fail('商户接口凭证不存在', 404); + } + + if (!$this->merchantApiCredentialService->delete((int) $data['id'])) { + return $this->fail('商户接口凭证删除失败'); + } + + return $this->success(true); + } +} + diff --git a/app/http/admin/controller/merchant/MerchantController.php b/app/http/admin/controller/merchant/MerchantController.php new file mode 100644 index 0000000..f3d105a --- /dev/null +++ b/app/http/admin/controller/merchant/MerchantController.php @@ -0,0 +1,141 @@ +validated($request->all(), MerchantValidator::class, 'index'); + $page = max(1, (int) ($data['page'] ?? 1)); + $pageSize = max(1, (int) ($data['page_size'] ?? 10)); + + return $this->success($this->merchantService->paginateWithGroupOptions($data, $page, $pageSize)); + } + + /** + * 查询商户详情。 + */ + public function show(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], MerchantValidator::class, 'show'); + $merchant = $this->merchantService->findById((int) $data['id']); + + if (!$merchant) { + return $this->fail('商户不存在', 404); + } + + return $this->success($merchant); + } + + /** + * 新增商户。 + */ + public function store(Request $request): Response + { + $data = $this->validated($request->all(), MerchantValidator::class, 'store'); + return $this->success($this->merchantService->createWithDetail($data)); + } + + /** + * 更新商户。 + */ + public function update(Request $request, string $id): Response + { + $payload = array_merge($request->all(), ['id' => (int) $id]); + $scene = count(array_diff(array_keys($request->all()), ['status'])) === 0 ? 'updateStatus' : 'update'; + $data = $this->validated($payload, MerchantValidator::class, $scene); + $merchant = $this->merchantService->updateWithDetail((int) $data['id'], $data); + + if (!$merchant) { + return $this->fail('商户不存在', 404); + } + + return $this->success($merchant); + } + + /** + * 删除商户。 + */ + public function destroy(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], MerchantValidator::class, 'destroy'); + + return $this->success($this->merchantService->delete((int) $data['id'])); + } + + /** + * 重置商户登录密码。 + */ + public function resetPassword(Request $request, string $id): Response + { + $payload = array_merge($request->all(), ['id' => (int) $id]); + $data = $this->validated($payload, MerchantValidator::class, 'resetPassword'); + + return $this->success($this->merchantService->resetPassword((int) $data['id'], (string) $data['password'])); + } + + /** + * 生成或重置商户接口凭证。 + */ + public function issueCredential(Request $request, string $id): Response + { + $merchantId = (int) $id; + + return $this->success($this->merchantService->issueCredential($merchantId)); + } + + /** + * 查询商户总览。 + */ + public function overview(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], MerchantValidator::class, 'overview'); + + return $this->success($this->merchantService->overview((int) $data['id'])); + } + + /** + * 查询商户下拉选项。 + */ + public function options(Request $request): Response + { + return $this->success($this->merchantService->enabledOptions()); + } + + /** + * 远程查询商户选择项。 + */ + public function selectOptions(Request $request): Response + { + $page = max(1, (int) $request->input('page', 1)); + $pageSize = min(50, max(1, (int) $request->input('page_size', 20))); + + return $this->success($this->merchantService->searchOptions($request->all(), $page, $pageSize)); + } +} + diff --git a/app/http/admin/controller/merchant/MerchantGroupController.php b/app/http/admin/controller/merchant/MerchantGroupController.php new file mode 100644 index 0000000..157505d --- /dev/null +++ b/app/http/admin/controller/merchant/MerchantGroupController.php @@ -0,0 +1,118 @@ +validated($request->all(), MerchantGroupValidator::class, 'index'); + + return $this->page( + $this->merchantGroupService->paginate( + $data, + (int) ($data['page'] ?? 1), + (int) ($data['page_size'] ?? 10) + ) + ); + } + + /** + * GET /admin/merchant-groups/{id} + * + * 查询商户分组详情。 + */ + public function show(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], MerchantGroupValidator::class, 'show'); + $merchantGroup = $this->merchantGroupService->findById((int) $data['id']); + + if (!$merchantGroup) { + return $this->fail('商户分组不存在', 404); + } + + return $this->success($merchantGroup); + } + + /** + * POST /admin/merchant-groups + * + * 新增商户分组。 + */ + public function store(Request $request): Response + { + $data = $this->validated($request->all(), MerchantGroupValidator::class, 'store'); + + return $this->success($this->merchantGroupService->create($data)); + } + + /** + * PUT /admin/merchant-groups/{id} + * + * 修改商户分组。 + */ + public function update(Request $request, string $id): Response + { + $data = $this->validated( + array_merge($request->all(), ['id' => (int) $id]), + MerchantGroupValidator::class, + 'update' + ); + + $merchantGroup = $this->merchantGroupService->update((int) $data['id'], $data); + if (!$merchantGroup) { + return $this->fail('商户分组不存在', 404); + } + + return $this->success($merchantGroup); + } + + /** + * DELETE /admin/merchant-groups/{id} + * + * 删除商户分组。 + */ + public function destroy(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], MerchantGroupValidator::class, 'destroy'); + + if (!$this->merchantGroupService->delete((int) $data['id'])) { + return $this->fail('商户分组不存在', 404); + } + + return $this->success(true); + } + + /** + * 查询商户分组下拉选项。 + */ + public function options(Request $request): Response + { + return $this->success($this->merchantGroupService->enabledOptions()); + } +} + diff --git a/app/http/admin/controller/merchant/MerchantPolicyController.php b/app/http/admin/controller/merchant/MerchantPolicyController.php new file mode 100644 index 0000000..8e4323c --- /dev/null +++ b/app/http/admin/controller/merchant/MerchantPolicyController.php @@ -0,0 +1,66 @@ +validated($request->all(), MerchantPolicyValidator::class, 'index'); + + return $this->page( + $this->merchantPolicyService->paginate( + $data, + (int) ($data['page'] ?? 1), + (int) ($data['page_size'] ?? 10) + ) + ); + } + + public function show(Request $request, string $merchantId): Response + { + $data = $this->validated(['merchant_id' => (int) $merchantId], MerchantPolicyValidator::class, 'show'); + + return $this->success($this->merchantPolicyService->findByMerchantId((int) $data['merchant_id'])); + } + + public function store(Request $request): Response + { + $data = $this->validated($request->all(), MerchantPolicyValidator::class, 'store'); + + return $this->success($this->merchantPolicyService->saveByMerchantId((int) $data['merchant_id'], $data)); + } + + public function update(Request $request, string $merchantId): Response + { + $data = $this->validated( + array_merge($request->all(), ['merchant_id' => (int) $merchantId]), + MerchantPolicyValidator::class, + 'update' + ); + + return $this->success($this->merchantPolicyService->saveByMerchantId((int) $data['merchant_id'], $data)); + } + + public function destroy(Request $request, string $merchantId): Response + { + $data = $this->validated(['merchant_id' => (int) $merchantId], MerchantPolicyValidator::class, 'show'); + + return $this->success($this->merchantPolicyService->deleteByMerchantId((int) $data['merchant_id'])); + } +} + diff --git a/app/http/admin/controller/ops/ChannelDailyStatController.php b/app/http/admin/controller/ops/ChannelDailyStatController.php new file mode 100644 index 0000000..747a564 --- /dev/null +++ b/app/http/admin/controller/ops/ChannelDailyStatController.php @@ -0,0 +1,54 @@ +validated($request->all(), ChannelDailyStatValidator::class, 'index'); + + return $this->page( + $this->channelDailyStatService->paginate( + $data, + (int) ($data['page'] ?? 1), + (int) ($data['page_size'] ?? 10) + ) + ); + } + + /** + * 查询通道日统计详情。 + */ + public function show(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], ChannelDailyStatValidator::class, 'show'); + $stat = $this->channelDailyStatService->findById((int) $data['id']); + + if (!$stat) { + return $this->fail('通道日统计不存在', 404); + } + + return $this->success($stat); + } +} diff --git a/app/http/admin/controller/ops/ChannelNotifyLogController.php b/app/http/admin/controller/ops/ChannelNotifyLogController.php new file mode 100644 index 0000000..164a12b --- /dev/null +++ b/app/http/admin/controller/ops/ChannelNotifyLogController.php @@ -0,0 +1,54 @@ +validated($request->all(), ChannelNotifyLogValidator::class, 'index'); + + return $this->page( + $this->channelNotifyLogService->paginate( + $data, + (int) ($data['page'] ?? 1), + (int) ($data['page_size'] ?? 10) + ) + ); + } + + /** + * 查询渠道通知日志详情。 + */ + public function show(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], ChannelNotifyLogValidator::class, 'show'); + $log = $this->channelNotifyLogService->findById((int) $data['id']); + + if (!$log) { + return $this->fail('渠道通知日志不存在', 404); + } + + return $this->success($log); + } +} diff --git a/app/http/admin/controller/ops/PayCallbackLogController.php b/app/http/admin/controller/ops/PayCallbackLogController.php new file mode 100644 index 0000000..3ad813f --- /dev/null +++ b/app/http/admin/controller/ops/PayCallbackLogController.php @@ -0,0 +1,54 @@ +validated($request->all(), PayCallbackLogValidator::class, 'index'); + + return $this->page( + $this->payCallbackLogService->paginate( + $data, + (int) ($data['page'] ?? 1), + (int) ($data['page_size'] ?? 10) + ) + ); + } + + /** + * 查询支付回调日志详情。 + */ + public function show(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], PayCallbackLogValidator::class, 'show'); + $log = $this->payCallbackLogService->findById((int) $data['id']); + + if (!$log) { + return $this->fail('支付回调日志不存在', 404); + } + + return $this->success($log); + } +} diff --git a/app/http/admin/controller/payment/PaymentChannelController.php b/app/http/admin/controller/payment/PaymentChannelController.php new file mode 100644 index 0000000..2da3293 --- /dev/null +++ b/app/http/admin/controller/payment/PaymentChannelController.php @@ -0,0 +1,138 @@ +validated($request->all(), PaymentChannelValidator::class, 'index'); + + return $this->page( + $this->paymentChannelService->paginate( + $data, + (int) ($data['page'] ?? 1), + (int) ($data['page_size'] ?? 10) + ) + ); + } + + /** + * GET /admin/payment-channels/{id} + * + * 查询支付通道详情。 + */ + public function show(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], PaymentChannelValidator::class, 'show'); + $paymentChannel = $this->paymentChannelService->findById((int) $data['id']); + + if (!$paymentChannel) { + return $this->fail('支付通道不存在', 404); + } + + return $this->success($paymentChannel); + } + + /** + * POST /admin/payment-channels + * + * 新增支付通道。 + */ + public function store(Request $request): Response + { + $data = $this->validated($request->all(), PaymentChannelValidator::class, 'store'); + + return $this->success($this->paymentChannelService->create($data)); + } + + /** + * PUT /admin/payment-channels/{id} + * + * 修改支付通道。 + */ + public function update(Request $request, string $id): Response + { + $payload = $request->all(); + $scene = array_diff(array_keys($payload), ['status']) === [] ? 'updateStatus' : 'update'; + $data = $this->validated( + array_merge($payload, ['id' => (int) $id]), + PaymentChannelValidator::class, + $scene + ); + + $paymentChannel = $this->paymentChannelService->update((int) $data['id'], $data); + if (!$paymentChannel) { + return $this->fail('支付通道不存在', 404); + } + + return $this->success($paymentChannel); + } + + /** + * DELETE /admin/payment-channels/{id} + * + * 删除支付通道。 + */ + public function destroy(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], PaymentChannelValidator::class, 'destroy'); + + if (!$this->paymentChannelService->delete((int) $data['id'])) { + return $this->fail('支付通道不存在', 404); + } + + return $this->success(true); + } + + /** + * 查询启用中的通道选项。 + */ + public function options(Request $request): Response + { + return $this->success($this->paymentChannelService->enabledOptions()); + } + + /** + * 查询路由编排场景下的通道选项。 + */ + public function routeOptions(Request $request): Response + { + return $this->success($this->paymentChannelService->routeOptions($request->all())); + } + + /** + * 远程查询支付通道选择项。 + */ + public function selectOptions(Request $request): Response + { + $page = max(1, (int) $request->input('page', 1)); + $pageSize = min(50, max(1, (int) $request->input('page_size', 20))); + + return $this->success($this->paymentChannelService->searchOptions($request->all(), $page, $pageSize)); + } +} diff --git a/app/http/admin/controller/payment/PaymentPluginConfController.php b/app/http/admin/controller/payment/PaymentPluginConfController.php new file mode 100644 index 0000000..ee4f23f --- /dev/null +++ b/app/http/admin/controller/payment/PaymentPluginConfController.php @@ -0,0 +1,98 @@ +validated($request->all(), PaymentPluginConfValidator::class, 'index'); + $page = max(1, (int) ($data['page'] ?? 1)); + $pageSize = max(1, (int) ($data['page_size'] ?? 10)); + + return $this->page($this->paymentPluginConfService->paginate($data, $page, $pageSize)); + } + + public function show(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], PaymentPluginConfValidator::class, 'show'); + $pluginConf = $this->paymentPluginConfService->findById((int) $data['id']); + + if (!$pluginConf) { + return $this->fail('插件配置不存在', 404); + } + + return $this->success($pluginConf); + } + + public function store(Request $request): Response + { + $data = $this->validated($request->all(), PaymentPluginConfValidator::class, 'store'); + + return $this->success($this->paymentPluginConfService->create($data)); + } + + public function update(Request $request, string $id): Response + { + $data = $this->validated( + array_merge($request->all(), ['id' => (int) $id]), + PaymentPluginConfValidator::class, + 'update' + ); + + $pluginConf = $this->paymentPluginConfService->update((int) $data['id'], $data); + if (!$pluginConf) { + return $this->fail('插件配置不存在', 404); + } + + return $this->success($pluginConf); + } + + public function destroy(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], PaymentPluginConfValidator::class, 'destroy'); + + if (!$this->paymentPluginConfService->delete((int) $data['id'])) { + return $this->fail('插件配置不存在', 404); + } + + return $this->success(true); + } + + public function options(Request $request): Response + { + $data = $this->validated($request->all(), PaymentPluginConfValidator::class, 'options'); + + return $this->success([ + 'configs' => $this->paymentPluginConfService->options((string) ($data['plugin_code'] ?? '')), + ]); + } + + /** + * 远程查询插件配置选项。 + */ + public function selectOptions(Request $request): Response + { + $data = $this->validated($request->all(), PaymentPluginConfValidator::class, 'selectOptions'); + $page = max(1, (int) ($data['page'] ?? 1)); + $pageSize = min(50, max(1, (int) ($data['page_size'] ?? 20))); + + return $this->success($this->paymentPluginConfService->searchOptions($data, $page, $pageSize)); + } +} diff --git a/app/http/admin/controller/payment/PaymentPluginController.php b/app/http/admin/controller/payment/PaymentPluginController.php new file mode 100644 index 0000000..d7997bc --- /dev/null +++ b/app/http/admin/controller/payment/PaymentPluginController.php @@ -0,0 +1,123 @@ +validated($request->all(), PaymentPluginValidator::class, 'index'); + $page = max(1, (int) ($data['page'] ?? 1)); + $pageSize = max(1, (int) ($data['page_size'] ?? 10)); + + return $this->page($this->paymentPluginService->paginate($data, $page, $pageSize)); + } + + /** + * 查询支付插件详情。 + */ + public function show(Request $request, string $code): Response + { + $data = $this->validated(['code' => $code], PaymentPluginValidator::class, 'show'); + $paymentPlugin = $this->paymentPluginService->findByCode((string) $data['code']); + + if (!$paymentPlugin) { + return $this->fail('支付插件不存在', 404); + } + + return $this->success($paymentPlugin); + } + + /** + * 修改支付插件。 + */ + public function update(Request $request, string $code): Response + { + $payload = $request->all(); + $scene = array_diff(array_keys($payload), ['status']) === [] ? 'updateStatus' : 'update'; + $data = $this->validated( + array_merge($payload, ['code' => $code]), + PaymentPluginValidator::class, + $scene + ); + + $paymentPlugin = $this->paymentPluginService->update((string) $data['code'], $data); + if (!$paymentPlugin) { + return $this->fail('支付插件不存在', 404); + } + + return $this->success($paymentPlugin); + } + + /** + * 从插件目录刷新同步支付插件。 + */ + public function refresh(Request $request): Response + { + return $this->success($this->paymentPluginService->refreshFromClasses()); + } + + /** + * 查询支付插件下拉选项。 + */ + public function options(Request $request): Response + { + return $this->success([ + 'plugins' => $this->paymentPluginService->enabledOptions(), + ]); + } + + /** + * 远程查询支付插件选项。 + */ + public function selectOptions(Request $request): Response + { + $data = $this->validated($request->all(), PaymentPluginValidator::class, 'selectOptions'); + $page = max(1, (int) ($data['page'] ?? 1)); + $pageSize = min(50, max(1, (int) ($data['page_size'] ?? 20))); + + return $this->success($this->paymentPluginService->searchOptions($data, $page, $pageSize)); + } + + /** + * 查询通道配置场景下的插件选项。 + */ + public function channelOptions(Request $request): Response + { + return $this->success([ + 'plugins' => $this->paymentPluginService->channelOptions(), + ]); + } + + /** + * 查询插件配置结构。 + */ + public function schema(Request $request, string $code): Response + { + $data = $this->validated(['code' => $code], PaymentPluginValidator::class, 'show'); + + return $this->success($this->paymentPluginService->getSchema((string) $data['code'])); + } +} diff --git a/app/http/admin/controller/payment/PaymentPollGroupBindController.php b/app/http/admin/controller/payment/PaymentPollGroupBindController.php new file mode 100644 index 0000000..7c017ae --- /dev/null +++ b/app/http/admin/controller/payment/PaymentPollGroupBindController.php @@ -0,0 +1,77 @@ +validated($request->all(), PaymentPollGroupBindValidator::class, 'index'); + + return $this->page( + $this->paymentPollGroupBindService->paginate( + $data, + (int) ($data['page'] ?? 1), + (int) ($data['page_size'] ?? 10) + ) + ); + } + + public function show(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], PaymentPollGroupBindValidator::class, 'show'); + $row = $this->paymentPollGroupBindService->findById((int) $data['id']); + if (!$row) { + return $this->fail('商户分组路由绑定不存在', 404); + } + + return $this->success($row); + } + + public function store(Request $request): Response + { + $data = $this->validated($request->all(), PaymentPollGroupBindValidator::class, 'store'); + + return $this->success($this->paymentPollGroupBindService->create($data)); + } + + public function update(Request $request, string $id): Response + { + $data = $this->validated( + array_merge($request->all(), ['id' => (int) $id]), + PaymentPollGroupBindValidator::class, + 'update' + ); + + $row = $this->paymentPollGroupBindService->update((int) $data['id'], $data); + if (!$row) { + return $this->fail('商户分组路由绑定不存在', 404); + } + + return $this->success($row); + } + + public function destroy(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], PaymentPollGroupBindValidator::class, 'destroy'); + if (!$this->paymentPollGroupBindService->delete((int) $data['id'])) { + return $this->fail('商户分组路由绑定不存在', 404); + } + + return $this->success(true); + } +} diff --git a/app/http/admin/controller/payment/PaymentPollGroupChannelController.php b/app/http/admin/controller/payment/PaymentPollGroupChannelController.php new file mode 100644 index 0000000..6d3113f --- /dev/null +++ b/app/http/admin/controller/payment/PaymentPollGroupChannelController.php @@ -0,0 +1,79 @@ +validated($request->all(), PaymentPollGroupChannelValidator::class, 'index'); + + return $this->page( + $this->paymentPollGroupChannelService->paginate( + $data, + (int) ($data['page'] ?? 1), + (int) ($data['page_size'] ?? 10) + ) + ); + } + + public function show(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], PaymentPollGroupChannelValidator::class, 'show'); + $row = $this->paymentPollGroupChannelService->findById((int) $data['id']); + if (!$row) { + return $this->fail('轮询组通道编排不存在', 404); + } + + return $this->success($row); + } + + public function store(Request $request): Response + { + $data = $this->validated($request->all(), PaymentPollGroupChannelValidator::class, 'store'); + + return $this->success($this->paymentPollGroupChannelService->create($data)); + } + + public function update(Request $request, string $id): Response + { + $payload = $request->all(); + $scene = array_diff(array_keys($payload), ['status']) === [] ? 'updateStatus' : 'update'; + $data = $this->validated( + array_merge($payload, ['id' => (int) $id]), + PaymentPollGroupChannelValidator::class, + $scene + ); + + $row = $this->paymentPollGroupChannelService->update((int) $data['id'], $data); + if (!$row) { + return $this->fail('轮询组通道编排不存在', 404); + } + + return $this->success($row); + } + + public function destroy(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], PaymentPollGroupChannelValidator::class, 'destroy'); + if (!$this->paymentPollGroupChannelService->delete((int) $data['id'])) { + return $this->fail('轮询组通道编排不存在', 404); + } + + return $this->success(true); + } +} diff --git a/app/http/admin/controller/payment/PaymentPollGroupController.php b/app/http/admin/controller/payment/PaymentPollGroupController.php new file mode 100644 index 0000000..ea9ab28 --- /dev/null +++ b/app/http/admin/controller/payment/PaymentPollGroupController.php @@ -0,0 +1,119 @@ +validated($request->all(), PaymentPollGroupValidator::class, 'index'); + + return $this->page( + $this->paymentPollGroupService->paginate( + $data, + (int) ($data['page'] ?? 1), + (int) ($data['page_size'] ?? 10) + ) + ); + } + + /** + * GET /admin/payment-poll-groups/{id} + * + * 查询轮询组详情。 + */ + public function show(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], PaymentPollGroupValidator::class, 'show'); + $paymentPollGroup = $this->paymentPollGroupService->findById((int) $data['id']); + + if (!$paymentPollGroup) { + return $this->fail('轮询组不存在', 404); + } + + return $this->success($paymentPollGroup); + } + + /** + * POST /admin/payment-poll-groups + * + * 新增轮询组。 + */ + public function store(Request $request): Response + { + $data = $this->validated($request->all(), PaymentPollGroupValidator::class, 'store'); + + return $this->success($this->paymentPollGroupService->create($data)); + } + + /** + * PUT /admin/payment-poll-groups/{id} + * + * 修改轮询组。 + */ + public function update(Request $request, string $id): Response + { + $payload = $request->all(); + $scene = array_diff(array_keys($payload), ['status']) === [] ? 'updateStatus' : 'update'; + $data = $this->validated( + array_merge($payload, ['id' => (int) $id]), + PaymentPollGroupValidator::class, + $scene + ); + + $paymentPollGroup = $this->paymentPollGroupService->update((int) $data['id'], $data); + if (!$paymentPollGroup) { + return $this->fail('轮询组不存在', 404); + } + + return $this->success($paymentPollGroup); + } + + /** + * DELETE /admin/payment-poll-groups/{id} + * + * 删除轮询组。 + */ + public function destroy(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], PaymentPollGroupValidator::class, 'destroy'); + + if (!$this->paymentPollGroupService->delete((int) $data['id'])) { + return $this->fail('轮询组不存在', 404); + } + + return $this->success(true); + } + + /** + * 查询轮询组下拉选项。 + */ + public function options(Request $request): Response + { + return $this->success($this->paymentPollGroupService->enabledOptions($request->all())); + } +} diff --git a/app/http/admin/controller/payment/PaymentTypeController.php b/app/http/admin/controller/payment/PaymentTypeController.php new file mode 100644 index 0000000..f100838 --- /dev/null +++ b/app/http/admin/controller/payment/PaymentTypeController.php @@ -0,0 +1,105 @@ +validated($request->all(), PaymentTypeValidator::class, 'index'); + $page = max(1, (int) ($data['page'] ?? 1)); + $pageSize = max(1, (int) ($data['page_size'] ?? 10)); + + return $this->page($this->paymentTypeService->paginate($data, $page, $pageSize)); + } + + /** + * 查询支付方式详情。 + */ + public function show(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], PaymentTypeValidator::class, 'show'); + $paymentType = $this->paymentTypeService->findById((int) $data['id']); + + if (!$paymentType) { + return $this->fail('支付方式不存在', 404); + } + + return $this->success($paymentType); + } + + /** + * 新增支付方式。 + */ + public function store(Request $request): Response + { + $data = $this->validated($request->all(), PaymentTypeValidator::class, 'store'); + + return $this->success($this->paymentTypeService->create($data)); + } + + /** + * 修改支付方式。 + */ + public function update(Request $request, string $id): Response + { + $payload = $request->all(); + $scene = array_diff(array_keys($payload), ['status']) === [] ? 'updateStatus' : 'update'; + $data = $this->validated( + array_merge($payload, ['id' => (int) $id]), + PaymentTypeValidator::class, + $scene + ); + + $paymentType = $this->paymentTypeService->update((int) $data['id'], $data); + if (!$paymentType) { + return $this->fail('支付方式不存在', 404); + } + + return $this->success($paymentType); + } + + /** + * 删除支付方式。 + */ + public function destroy(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], PaymentTypeValidator::class, 'destroy'); + + if (!$this->paymentTypeService->delete((int) $data['id'])) { + return $this->fail('支付方式不存在', 404); + } + + return $this->success(true); + } + + /** + * 查询支付方式下拉选项。 + */ + public function options(Request $request): Response + { + return $this->success($this->paymentTypeService->enabledOptions()); + } +} diff --git a/app/http/admin/controller/payment/RouteController.php b/app/http/admin/controller/payment/RouteController.php new file mode 100644 index 0000000..5dac95b --- /dev/null +++ b/app/http/admin/controller/payment/RouteController.php @@ -0,0 +1,43 @@ +validated($request->all(), RouteResolveValidator::class, 'resolve'); + + return $this->success($this->paymentRouteService->resolveByMerchantGroup( + (int) $data['merchant_group_id'], + (int) $data['pay_type_id'], + (int) $data['pay_amount'], + $data + )); + } +} + diff --git a/app/http/admin/controller/system/AdminUserController.php b/app/http/admin/controller/system/AdminUserController.php new file mode 100644 index 0000000..ccfd963 --- /dev/null +++ b/app/http/admin/controller/system/AdminUserController.php @@ -0,0 +1,122 @@ +validated($request->all(), AdminUserValidator::class, 'index'); + + return $this->page( + $this->adminUserService->paginate( + $data, + (int) ($data['page'] ?? 1), + (int) ($data['page_size'] ?? 10) + ) + ); + } + + /** + * GET /adminapi/admin-users/{id} + * + * 查询管理员用户详情。 + */ + public function show(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], AdminUserValidator::class, 'show'); + $adminUser = $this->adminUserService->findById((int) $data['id']); + + if (!$adminUser) { + return $this->fail('管理员用户不存在', 404); + } + + return $this->success($adminUser); + } + + /** + * POST /adminapi/admin-users + * + * 新增管理员用户。 + */ + public function store(Request $request): Response + { + $data = $this->validated($request->all(), AdminUserValidator::class, 'store'); + + return $this->success($this->adminUserService->create($data)); + } + + /** + * PUT /adminapi/admin-users/{id} + * + * 修改管理员用户。 + */ + public function update(Request $request, string $id): Response + { + $data = $this->validated( + array_merge($request->all(), ['id' => (int) $id]), + AdminUserValidator::class, + 'update' + ); + + $adminUser = $this->adminUserService->update((int) $data['id'], $data); + if (!$adminUser) { + return $this->fail('管理员用户不存在', 404); + } + + return $this->success($adminUser); + } + + /** + * DELETE /adminapi/admin-users/{id} + * + * 删除管理员用户。 + */ + public function destroy(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], AdminUserValidator::class, 'destroy'); + $adminUser = $this->adminUserService->findById((int) $data['id']); + + if (!$adminUser) { + return $this->fail('管理员用户不存在', 404); + } + + if ((int) $adminUser->is_super === 1) { + return $this->fail('超级管理员不允许删除'); + } + + if ((int) $data['id'] === $this->currentAdminId($request)) { + return $this->fail('不允许删除当前登录用户'); + } + + if (!$this->adminUserService->delete((int) $data['id'])) { + return $this->fail('管理员用户删除失败'); + } + + return $this->success(true); + } +} diff --git a/app/http/admin/controller/system/AuthController.php b/app/http/admin/controller/system/AuthController.php new file mode 100644 index 0000000..8bccae2 --- /dev/null +++ b/app/http/admin/controller/system/AuthController.php @@ -0,0 +1,65 @@ +validated($request->all(), AuthValidator::class, 'login'); + + return $this->success($this->adminAuthService->authenticateCredentials( + (string) $data['username'], + (string) $data['password'], + $request->getRealIp(), + $request->header('user-agent', '') + )); + } + + public function logout(Request $request): Response + { + $token = trim((string) ($request->header('authorization', '') ?: $request->header('x-admin-token', ''))); + $token = preg_replace('/^Bearer\s+/i', '', $token) ?: $token; + + if ($token === '') { + return $this->fail('未获取到登录令牌', 401); + } + + $this->adminAuthService->revokeToken($token); + + return $this->success(true); + } + + /** + * 获取当前登录管理员的信息 + */ + public function profile(Request $request): Response + { + $adminId = $this->currentAdminId($request); + if ($adminId <= 0) { + return $this->fail('未获取到当前管理员信息', 401); + } + + return $this->success($this->adminUserService->profile( + $adminId, + (string) $this->requestAttribute($request, 'auth.admin_username', '') + )); + } +} + diff --git a/app/http/admin/controller/system/SystemConfigPageController.php b/app/http/admin/controller/system/SystemConfigPageController.php new file mode 100644 index 0000000..1a9d5d3 --- /dev/null +++ b/app/http/admin/controller/system/SystemConfigPageController.php @@ -0,0 +1,42 @@ +success($this->systemConfigPageService->tabs()); + } + + public function show(Request $request, string $groupCode): Response + { + $data = $this->validated(['group_code' => $groupCode], SystemConfigPageValidator::class, 'show'); + + return $this->success($this->systemConfigPageService->detail((string) $data['group_code'])); + } + + public function store(Request $request, string $groupCode): Response + { + $data = $this->validated( + array_merge($this->payload($request), ['group_code' => $groupCode]), + SystemConfigPageValidator::class, + 'store' + ); + + return $this->success( + $this->systemConfigPageService->save((string) $data['group_code'], (array) ($data['values'] ?? [])) + ); + } +} diff --git a/app/http/admin/controller/system/SystemController.php b/app/http/admin/controller/system/SystemController.php new file mode 100644 index 0000000..e7fa406 --- /dev/null +++ b/app/http/admin/controller/system/SystemController.php @@ -0,0 +1,30 @@ +success($this->systemBootstrapService->getMenuTree('admin')); + } + + public function dictItems(Request $request): Response + { + return $this->success($this->systemBootstrapService->getDictItems((string) $request->get('code', ''))); + } +} + diff --git a/app/http/admin/controller/trade/PayOrderController.php b/app/http/admin/controller/trade/PayOrderController.php new file mode 100644 index 0000000..d5c43e5 --- /dev/null +++ b/app/http/admin/controller/trade/PayOrderController.php @@ -0,0 +1,38 @@ +validated($request->all(), PayOrderValidator::class, 'index'); + $page = max(1, (int) ($data['page'] ?? 1)); + $pageSize = max(1, (int) ($data['page_size'] ?? 10)); + + return $this->success($this->payOrderService->paginate($data, $page, $pageSize)); + } +} + diff --git a/app/http/admin/controller/trade/RefundOrderController.php b/app/http/admin/controller/trade/RefundOrderController.php new file mode 100644 index 0000000..e1f7489 --- /dev/null +++ b/app/http/admin/controller/trade/RefundOrderController.php @@ -0,0 +1,56 @@ +validated($request->all(), RefundOrderValidator::class, 'index'); + $page = max(1, (int) ($data['page'] ?? 1)); + $pageSize = max(1, (int) ($data['page_size'] ?? 10)); + + return $this->success($this->refundService->paginate($data, $page, $pageSize)); + } + + /** + * 查询退款订单详情。 + */ + public function show(Request $request, string $refundNo): Response + { + return $this->success($this->refundService->detail($refundNo)); + } + + /** + * 重试退款。 + */ + public function retry(Request $request, string $refundNo): Response + { + $data = $this->validated( + array_merge($request->all(), ['refund_no' => $refundNo]), + RefundActionValidator::class, + 'retry' + ); + + return $this->success($this->refundService->retryRefund($refundNo, $data)); + } +} + diff --git a/app/http/admin/controller/trade/SettlementOrderController.php b/app/http/admin/controller/trade/SettlementOrderController.php new file mode 100644 index 0000000..d48fe4d --- /dev/null +++ b/app/http/admin/controller/trade/SettlementOrderController.php @@ -0,0 +1,53 @@ +validated($request->all(), SettlementOrderValidator::class, 'index'); + + return $this->page( + $this->settlementOrderQueryService->paginate( + $data, + (int) ($data['page'] ?? 1), + (int) ($data['page_size'] ?? 10) + ) + ); + } + + /** + * 查询清算订单详情。 + */ + public function show(Request $request, string $settleNo): Response + { + $data = $this->validated(['settle_no' => $settleNo], SettlementOrderValidator::class, 'show'); + try { + return $this->success($this->settlementOrderQueryService->detail((string) $data['settle_no'])); + } catch (ResourceNotFoundException) { + return $this->fail('清算订单不存在', 404); + } + } +} diff --git a/app/http/admin/middleware/AdminAuthMiddleware.php b/app/http/admin/middleware/AdminAuthMiddleware.php new file mode 100644 index 0000000..851e69d --- /dev/null +++ b/app/http/admin/middleware/AdminAuthMiddleware.php @@ -0,0 +1,62 @@ +header('authorization', '') ?: $request->header('x-admin-token', ''))); + $token = preg_replace('/^Bearer\s+/i', '', $token) ?: $token; + + if ($token === '') { + if ((int) env('AUTH_MIDDLEWARE_STRICT', 1) === 1) { + return json([ + 'code' => 401, + 'msg' => 'admin unauthorized', + 'data' => null, + ]); + } + } else { + $admin = $this->adminAuthService->authenticateToken( + $token, + $request->getRealIp(), + $request->header('user-agent', '') + ); + if (!$admin) { + return json([ + 'code' => 401, + 'msg' => 'admin unauthorized', + 'data' => null, + ]); + } + + Context::set('auth.admin_id', (int) $admin->id); + Context::set('auth.admin_username', (string) $admin->username); + } + + return $handler($request); + } +} diff --git a/app/http/admin/middleware/AuthMiddleware.php b/app/http/admin/middleware/AuthMiddleware.php deleted file mode 100644 index 45f8501..0000000 --- a/app/http/admin/middleware/AuthMiddleware.php +++ /dev/null @@ -1,73 +0,0 @@ -header('Authorization', ''); - if (!$auth) { - throw new UnauthorizedException('缺少认证令牌'); - } - - // 兼容 "Bearer xxx" 或直接 "xxx" - if (str_starts_with($auth, 'Bearer ')) { - $token = substr($auth, 7); - } else { - $token = $auth; - } - - if (!$token) { - throw new UnauthorizedException('认证令牌格式错误'); - } - - try { - // 解析 JWT token - $payload = JwtUtil::parseToken($token); - - if (empty($payload) || !isset($payload['user_id'])) { - throw new UnauthorizedException('认证令牌无效'); - } - - // 将用户信息存储到请求对象中,供控制器使用 - $request->user = $payload; - $request->userId = (int) ($payload['user_id'] ?? 0); - - // 继续处理请求 - return $handler($request); - } catch (UnauthorizedException $e) { - // 重新抛出业务异常,让框架处理 - throw $e; - } catch (\Throwable $e) { - // 根据异常类型返回不同的错误信息 - $message = $e->getMessage(); - if (str_contains($message, 'expired') || str_contains($message, 'Expired')) { - throw new UnauthorizedException('认证令牌已过期'); - } elseif (str_contains($message, 'signature') || str_contains($message, 'Signature')) { - throw new UnauthorizedException('认证令牌签名无效'); - } else { - throw new UnauthorizedException('认证令牌验证失败:' . $message); - } - } - } -} - diff --git a/app/http/admin/validation/AdminUserValidator.php b/app/http/admin/validation/AdminUserValidator.php new file mode 100644 index 0000000..ff3128a --- /dev/null +++ b/app/http/admin/validation/AdminUserValidator.php @@ -0,0 +1,83 @@ + 'sometimes|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'username' => 'sometimes|string|alpha_dash|min:2|max:32', + 'password' => 'nullable|string|min:6|max:64', + 'real_name' => 'sometimes|string|min:2|max:50', + 'mobile' => 'nullable|string|max:20', + 'email' => 'nullable|email|max:100', + 'is_super' => 'sometimes|integer|in:0,1', + 'status' => 'sometimes|integer|in:0,1', + 'remark' => 'nullable|string|max:500', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'id' => '管理员ID', + 'keyword' => '关键词', + 'username' => '登录账号', + 'password' => '登录密码', + 'real_name' => '真实姓名', + 'mobile' => '手机号', + 'email' => '邮箱', + 'is_super' => '超级管理员', + 'status' => '状态', + 'remark' => '备注', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'status', 'is_super', 'page', 'page_size'], + 'store' => ['username', 'password', 'real_name', 'mobile', 'email', 'is_super', 'status', 'remark'], + 'update' => ['id', 'username', 'password', 'real_name', 'mobile', 'email', 'is_super', 'status', 'remark'], + 'show' => ['id'], + 'destroy' => ['id'], + ]; + + public function sceneStore(): static + { + return $this->appendRules([ + 'username' => 'required|string|alpha_dash|min:2|max:32', + 'password' => 'required|string|min:6|max:64', + 'real_name' => 'required|string|min:2|max:50', + 'is_super' => 'required|integer|in:0,1', + 'status' => 'required|integer|in:0,1', + ]); + } + + public function sceneUpdate(): static + { + return $this->appendRules([ + 'id' => 'required|integer|min:1', + 'username' => 'required|string|alpha_dash|min:2|max:32', + 'real_name' => 'required|string|min:2|max:50', + 'is_super' => 'required|integer|in:0,1', + 'status' => 'required|integer|in:0,1', + ]); + } + + public function sceneShow(): static + { + return $this->appendRules([ + 'id' => 'required|integer|min:1', + ]); + } + + public function sceneDestroy(): static + { + return $this->sceneShow(); + } +} diff --git a/app/http/admin/validation/AuthValidator.php b/app/http/admin/validation/AuthValidator.php new file mode 100644 index 0000000..7662921 --- /dev/null +++ b/app/http/admin/validation/AuthValidator.php @@ -0,0 +1,27 @@ + 'required|string|min:1|max:32', + 'password' => 'required|string|min:6|max:100', + ]; + + protected array $attributes = [ + 'username' => '用户名', + 'password' => '密码', + ]; + + protected array $scenes = [ + 'login' => ['username', 'password'], + ]; +} diff --git a/app/http/admin/validation/ChannelDailyStatValidator.php b/app/http/admin/validation/ChannelDailyStatValidator.php new file mode 100644 index 0000000..4a9a883 --- /dev/null +++ b/app/http/admin/validation/ChannelDailyStatValidator.php @@ -0,0 +1,36 @@ + 'required|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'merchant_id' => 'sometimes|integer|min:1', + 'channel_id' => 'sometimes|integer|min:1', + 'stat_date' => 'sometimes|date_format:Y-m-d', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'id' => '统计ID', + 'keyword' => '关键词', + 'merchant_id' => '所属商户', + 'channel_id' => '所属通道', + 'stat_date' => '统计日期', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'merchant_id', 'channel_id', 'stat_date', 'page', 'page_size'], + 'show' => ['id'], + ]; +} diff --git a/app/http/admin/validation/ChannelNotifyLogValidator.php b/app/http/admin/validation/ChannelNotifyLogValidator.php new file mode 100644 index 0000000..094efc6 --- /dev/null +++ b/app/http/admin/validation/ChannelNotifyLogValidator.php @@ -0,0 +1,40 @@ + 'required|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'merchant_id' => 'sometimes|integer|min:1', + 'channel_id' => 'sometimes|integer|min:1', + 'notify_type' => 'sometimes|integer|in:0,1', + 'verify_status' => 'sometimes|integer|in:0,1,2', + 'process_status' => 'sometimes|integer|in:0,1,2', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'id' => '日志ID', + 'keyword' => '关键词', + 'merchant_id' => '所属商户', + 'channel_id' => '所属通道', + 'notify_type' => '通知类型', + 'verify_status' => '验签状态', + 'process_status' => '处理状态', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'merchant_id', 'channel_id', 'notify_type', 'verify_status', 'process_status', 'page', 'page_size'], + 'show' => ['id'], + ]; +} diff --git a/app/http/admin/validation/FileRecordValidator.php b/app/http/admin/validation/FileRecordValidator.php new file mode 100644 index 0000000..23da680 --- /dev/null +++ b/app/http/admin/validation/FileRecordValidator.php @@ -0,0 +1,45 @@ + 'sometimes|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'scene' => 'nullable|integer|in:1,2,3,4', + 'source_type' => 'nullable|integer|in:1,2', + 'visibility' => 'nullable|integer|in:1,2', + 'storage_engine' => 'nullable|integer|in:1,2,3,4', + 'remote_url' => 'nullable|string|max:2048|url', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'id' => '文件ID', + 'keyword' => '关键字', + 'scene' => '文件场景', + 'source_type' => '来源类型', + 'visibility' => '可见性', + 'storage_engine' => '存储引擎', + 'remote_url' => '远程地址', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'scene', 'source_type', 'visibility', 'storage_engine', 'page', 'page_size'], + 'show' => ['id'], + 'destroy' => ['id'], + 'preview' => ['id'], + 'download' => ['id'], + 'store' => ['scene', 'visibility'], + 'importRemote' => ['remote_url', 'scene', 'visibility'], + ]; +} diff --git a/app/http/admin/validation/MerchantAccountLedgerValidator.php b/app/http/admin/validation/MerchantAccountLedgerValidator.php new file mode 100644 index 0000000..bd416de --- /dev/null +++ b/app/http/admin/validation/MerchantAccountLedgerValidator.php @@ -0,0 +1,38 @@ + 'required|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'merchant_id' => 'sometimes|integer|min:1', + 'biz_type' => 'sometimes|integer|min:0', + 'event_type' => 'sometimes|integer|min:0', + 'direction' => 'sometimes|integer|in:0,1', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'id' => '流水ID', + 'keyword' => '关键词', + 'merchant_id' => '所属商户', + 'biz_type' => '业务类型', + 'event_type' => '事件类型', + 'direction' => '方向', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'merchant_id', 'biz_type', 'event_type', 'direction', 'page', 'page_size'], + 'show' => ['id'], + ]; +} diff --git a/app/http/admin/validation/MerchantAccountValidator.php b/app/http/admin/validation/MerchantAccountValidator.php new file mode 100644 index 0000000..112fe11 --- /dev/null +++ b/app/http/admin/validation/MerchantAccountValidator.php @@ -0,0 +1,32 @@ + 'required|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'merchant_id' => 'sometimes|integer|min:1', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'id' => '账户ID', + 'keyword' => '关键词', + 'merchant_id' => '所属商户', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'merchant_id', 'page', 'page_size'], + 'show' => ['id'], + ]; +} diff --git a/app/http/admin/validation/MerchantApiCredentialValidator.php b/app/http/admin/validation/MerchantApiCredentialValidator.php new file mode 100644 index 0000000..c12d1ca --- /dev/null +++ b/app/http/admin/validation/MerchantApiCredentialValidator.php @@ -0,0 +1,71 @@ + 'sometimes|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'merchant_id' => 'sometimes|integer|min:1|exists:ma_merchant,id', + 'sign_type' => 'sometimes|integer|in:0', + 'api_key' => 'nullable|string|max:128', + 'status' => 'sometimes|integer|in:0,1', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'id' => '凭证ID', + 'keyword' => '关键词', + 'merchant_id' => '所属商户', + 'sign_type' => '签名类型', + 'api_key' => '接口凭证值', + 'status' => '状态', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'merchant_id', 'status', 'page', 'page_size'], + 'store' => ['merchant_id', 'sign_type', 'api_key', 'status'], + 'update' => ['id', 'sign_type', 'api_key', 'status'], + 'show' => ['id'], + 'destroy' => ['id'], + ]; + + public function sceneStore(): static + { + return $this->appendRules([ + 'merchant_id' => 'required|integer|min:1|exists:ma_merchant,id', + 'sign_type' => 'required|integer|in:0', + 'status' => 'required|integer|in:0,1', + ]); + } + + public function sceneUpdate(): static + { + return $this->appendRules([ + 'id' => 'required|integer|min:1', + 'sign_type' => 'required|integer|in:0', + 'status' => 'required|integer|in:0,1', + ]); + } + + public function sceneShow(): static + { + return $this->appendRules([ + 'id' => 'required|integer|min:1', + ]); + } + + public function sceneDestroy(): static + { + return $this->sceneShow(); + } +} diff --git a/app/http/admin/validation/MerchantGroupValidator.php b/app/http/admin/validation/MerchantGroupValidator.php new file mode 100644 index 0000000..7caa5b2 --- /dev/null +++ b/app/http/admin/validation/MerchantGroupValidator.php @@ -0,0 +1,70 @@ + 'sometimes|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'group_name' => 'sometimes|string|min:2|max:128', + 'status' => 'sometimes|integer|in:0,1', + 'remark' => 'nullable|string|max:255', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'id' => '分组ID', + 'keyword' => '关键字', + 'group_name' => '分组名称', + 'status' => '状态', + 'remark' => '备注', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'group_name', 'status', 'page', 'page_size'], + 'store' => ['group_name', 'status', 'remark'], + 'update' => ['id', 'group_name', 'status', 'remark'], + 'show' => ['id'], + 'destroy' => ['id'], + ]; + + public function sceneStore(): static + { + return $this->appendRules([ + 'group_name' => 'required|string|min:2|max:128', + 'status' => 'required|integer|in:0,1', + ]); + } + + public function sceneUpdate(): static + { + return $this->appendRules([ + 'id' => 'required|integer|min:1', + 'group_name' => 'required|string|min:2|max:128', + 'status' => 'required|integer|in:0,1', + ]); + } + + public function sceneShow(): static + { + return $this->appendRules([ + 'id' => 'required|integer|min:1', + ]); + } + + public function sceneDestroy(): static + { + return $this->sceneShow(); + } +} diff --git a/app/http/admin/validation/MerchantPolicyValidator.php b/app/http/admin/validation/MerchantPolicyValidator.php new file mode 100644 index 0000000..bf524bd --- /dev/null +++ b/app/http/admin/validation/MerchantPolicyValidator.php @@ -0,0 +1,89 @@ + 'sometimes|integer|min:1', + 'merchant_id' => 'sometimes|integer|min:1|exists:ma_merchant,id', + 'keyword' => 'sometimes|string|max:128', + 'group_id' => 'sometimes|integer|min:1|exists:ma_merchant_group,id', + 'has_policy' => 'sometimes|integer|in:0,1', + 'settlement_cycle_override' => 'sometimes|integer|in:0,1,2,3,4', + 'auto_payout' => 'sometimes|integer|in:0,1', + 'min_settlement_amount' => 'sometimes|integer|min:0', + 'retry_policy_json' => 'sometimes|array', + 'route_policy_json' => 'sometimes|array', + 'fee_rule_override_json' => 'sometimes|array', + 'risk_policy_json' => 'sometimes|array', + 'remark' => 'sometimes|string|max:500', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'id' => '策略ID', + 'merchant_id' => '所属商户', + 'keyword' => '关键字', + 'group_id' => '商户分组', + 'has_policy' => '策略状态', + 'settlement_cycle_override' => '结算周期覆盖', + 'auto_payout' => '自动处理', + 'min_settlement_amount' => '最小结算金额', + 'retry_policy_json' => '重试策略', + 'route_policy_json' => '路由策略', + 'fee_rule_override_json' => '费率覆盖策略', + 'risk_policy_json' => '风控策略', + 'remark' => '备注', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'merchant_id', 'group_id', 'has_policy', 'settlement_cycle_override', 'auto_payout', 'page', 'page_size'], + 'show' => ['merchant_id'], + 'store' => [ + 'merchant_id', + 'settlement_cycle_override', + 'auto_payout', + 'min_settlement_amount', + 'retry_policy_json', + 'route_policy_json', + 'fee_rule_override_json', + 'risk_policy_json', + 'remark', + ], + 'update' => [ + 'merchant_id', + 'settlement_cycle_override', + 'auto_payout', + 'min_settlement_amount', + 'retry_policy_json', + 'route_policy_json', + 'fee_rule_override_json', + 'risk_policy_json', + 'remark', + ], + ]; + + public function sceneStore(): static + { + return $this->appendRules([ + 'merchant_id' => 'required|integer|min:1|exists:ma_merchant,id', + 'settlement_cycle_override' => 'required|integer|in:0,1,2,3,4', + 'auto_payout' => 'required|integer|in:0,1', + 'min_settlement_amount' => 'required|integer|min:0', + ]); + } + + public function sceneUpdate(): static + { + return $this->sceneStore(); + } +} diff --git a/app/http/admin/validation/MerchantValidator.php b/app/http/admin/validation/MerchantValidator.php new file mode 100644 index 0000000..5f9e4cf --- /dev/null +++ b/app/http/admin/validation/MerchantValidator.php @@ -0,0 +1,154 @@ + 'sometimes|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'group_id' => 'sometimes|integer|min:1', + 'status' => 'sometimes|integer|in:0,1', + 'merchant_type' => 'sometimes|integer|in:0,1,2', + 'merchant_no' => 'sometimes|string|max:32', + 'merchant_name' => 'sometimes|string|max:100', + 'merchant_short_name' => 'sometimes|string|max:60', + 'password' => 'sometimes|string|min:6|max:32', + 'password_confirm' => 'sometimes|string|min:6|max:32|same:password', + 'risk_level' => 'sometimes|integer|in:0,1,2', + 'contact_name' => 'sometimes|string|max:50', + 'contact_phone' => 'sometimes|string|max:20', + 'contact_email' => 'sometimes|string|max:100', + 'settlement_account_name' => 'sometimes|string|max:100', + 'settlement_account_no' => 'sometimes|string|max:100', + 'settlement_bank_name' => 'sometimes|string|max:100', + 'settlement_bank_branch' => 'sometimes|string|max:100', + 'remark' => 'sometimes|string|max:500', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'id' => '商户ID', + 'keyword' => '关键字', + 'group_id' => '商户分组', + 'status' => '状态', + 'merchant_type' => '商户类型', + 'merchant_no' => '商户号', + 'merchant_name' => '商户名称', + 'merchant_short_name' => '商户简称', + 'password' => '登录密码', + 'password_confirm' => '确认密码', + 'risk_level' => '风控等级', + 'contact_name' => '联系人', + 'contact_phone' => '联系电话', + 'contact_email' => '联系邮箱', + 'settlement_account_name' => '结算账户名', + 'settlement_account_no' => '结算账号', + 'settlement_bank_name' => '开户行', + 'settlement_bank_branch' => '开户支行', + 'remark' => '备注', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'group_id', 'status', 'merchant_type', 'risk_level', 'page', 'page_size'], + 'show' => ['id'], + 'overview' => ['id'], + 'store' => [ + 'merchant_name', + 'merchant_short_name', + 'merchant_type', + 'group_id', + 'risk_level', + 'contact_name', + 'contact_phone', + 'contact_email', + 'settlement_account_name', + 'settlement_account_no', + 'settlement_bank_name', + 'settlement_bank_branch', + 'status', + 'remark', + ], + 'update' => [ + 'id', + 'merchant_name', + 'merchant_short_name', + 'merchant_type', + 'group_id', + 'risk_level', + 'contact_name', + 'contact_phone', + 'contact_email', + 'settlement_account_name', + 'settlement_account_no', + 'settlement_bank_name', + 'settlement_bank_branch', + 'status', + 'remark', + ], + 'updateStatus' => ['id', 'status'], + 'resetPassword' => ['id', 'password', 'password_confirm'], + 'destroy' => ['id'], + ]; + + public function sceneStore(): static + { + return $this->appendRules([ + 'merchant_name' => 'required|string|max:100', + 'merchant_type' => 'required|integer|in:0,1,2', + 'group_id' => 'required|integer|min:1|exists:ma_merchant_group,id', + 'risk_level' => 'required|integer|in:0,1,2', + 'contact_name' => 'required|string|max:50', + 'contact_phone' => 'required|string|max:20', + 'status' => 'required|integer|in:0,1', + ]); + } + + public function sceneUpdate(): static + { + return $this->appendRules([ + 'id' => 'required|integer|min:1', + 'merchant_name' => 'required|string|max:100', + 'merchant_type' => 'required|integer|in:0,1,2', + 'group_id' => 'required|integer|min:1|exists:ma_merchant_group,id', + 'risk_level' => 'required|integer|in:0,1,2', + 'contact_name' => 'required|string|max:50', + 'contact_phone' => 'required|string|max:20', + 'status' => 'required|integer|in:0,1', + ]); + } + + public function sceneUpdateStatus(): static + { + return $this->appendRules([ + 'id' => 'required|integer|min:1', + 'status' => 'required|integer|in:0,1', + ]); + } + + public function sceneResetPassword(): static + { + return $this->appendRules([ + 'id' => 'required|integer|min:1', + 'password' => 'required|string|min:6|max:32', + 'password_confirm' => 'required|string|min:6|max:32|same:password', + ]); + } + + public function sceneDestroy(): static + { + return $this->appendRules([ + 'id' => 'required|integer|min:1', + ]); + } +} diff --git a/app/http/admin/validation/PayCallbackLogValidator.php b/app/http/admin/validation/PayCallbackLogValidator.php new file mode 100644 index 0000000..5110263 --- /dev/null +++ b/app/http/admin/validation/PayCallbackLogValidator.php @@ -0,0 +1,40 @@ + 'required|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'merchant_id' => 'sometimes|integer|min:1', + 'channel_id' => 'sometimes|integer|min:1', + 'callback_type' => 'sometimes|integer|in:0,1', + 'verify_status' => 'sometimes|integer|in:0,1,2', + 'process_status' => 'sometimes|integer|in:0,1,2', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'id' => '日志ID', + 'keyword' => '关键词', + 'merchant_id' => '所属商户', + 'channel_id' => '所属通道', + 'callback_type' => '回调类型', + 'verify_status' => '验签状态', + 'process_status' => '处理状态', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'merchant_id', 'channel_id', 'callback_type', 'verify_status', 'process_status', 'page', 'page_size'], + 'show' => ['id'], + ]; +} diff --git a/app/http/admin/validation/PayOrderValidator.php b/app/http/admin/validation/PayOrderValidator.php new file mode 100644 index 0000000..5cfbcfd --- /dev/null +++ b/app/http/admin/validation/PayOrderValidator.php @@ -0,0 +1,39 @@ + 'sometimes|string|max:128', + 'merchant_id' => 'sometimes|integer|min:1', + 'pay_type_id' => 'sometimes|integer|min:1', + 'status' => 'sometimes|integer|in:0,1,2,3,4,5', + 'channel_mode' => 'sometimes|integer|in:0,1', + 'callback_status' => 'sometimes|integer|in:0,1,2', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'keyword' => '关键字', + 'merchant_id' => '商户ID', + 'pay_type_id' => '支付方式', + 'status' => '状态', + 'channel_mode' => '通道模式', + 'callback_status' => '回调状态', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'merchant_id', 'pay_type_id', 'status', 'channel_mode', 'callback_status', 'page', 'page_size'], + ]; +} diff --git a/app/http/admin/validation/PaymentChannelValidator.php b/app/http/admin/validation/PaymentChannelValidator.php new file mode 100644 index 0000000..0118881 --- /dev/null +++ b/app/http/admin/validation/PaymentChannelValidator.php @@ -0,0 +1,103 @@ + 'sometimes|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'merchant_id' => 'sometimes|integer|min:0', + 'name' => 'sometimes|string|min:2|max:128', + 'split_rate_bp' => 'sometimes|integer|min:0|max:10000', + 'cost_rate_bp' => 'sometimes|integer|min:0|max:10000', + 'channel_mode' => 'sometimes|integer|in:0,1', + 'pay_type_id' => 'sometimes|integer|min:1|exists:ma_payment_type,id', + 'plugin_code' => 'sometimes|string|min:1|max:64|exists:ma_payment_plugin,code', + 'api_config_id' => 'nullable|integer|min:1', + 'daily_limit_amount' => 'nullable|integer|min:0', + 'daily_limit_count' => 'nullable|integer|min:0', + 'min_amount' => 'nullable|integer|min:0', + 'max_amount' => 'nullable|integer|min:0', + 'remark' => 'nullable|string|max:255', + 'status' => 'sometimes|integer|in:0,1', + 'sort_no' => 'nullable|integer|min:0', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'id' => '通道ID', + 'keyword' => '关键字', + 'merchant_id' => '所属商户', + 'name' => '通道名称', + 'split_rate_bp' => '分成比例', + 'cost_rate_bp' => '通道成本', + 'channel_mode' => '通道模式', + 'pay_type_id' => '支付方式', + 'plugin_code' => '支付插件', + 'api_config_id' => '配置ID', + 'daily_limit_amount' => '单日限额', + 'daily_limit_count' => '单日限笔', + 'min_amount' => '最小金额', + 'max_amount' => '最大金额', + 'remark' => '备注', + 'status' => '状态', + 'sort_no' => '排序', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'merchant_id', 'pay_type_id', 'plugin_code', 'channel_mode', 'status', 'page', 'page_size'], + 'store' => ['merchant_id', 'name', 'split_rate_bp', 'cost_rate_bp', 'channel_mode', 'pay_type_id', 'plugin_code', 'api_config_id', 'daily_limit_amount', 'daily_limit_count', 'min_amount', 'max_amount', 'remark', 'status', 'sort_no'], + 'update' => ['id', 'merchant_id', 'name', 'split_rate_bp', 'cost_rate_bp', 'channel_mode', 'pay_type_id', 'plugin_code', 'api_config_id', 'daily_limit_amount', 'daily_limit_count', 'min_amount', 'max_amount', 'remark', 'status', 'sort_no'], + 'updateStatus' => ['id', 'status'], + 'show' => ['id'], + 'destroy' => ['id'], + ]; + + public function rules(): array + { + $rules = parent::rules(); + + return match ($this->scene()) { + 'store' => array_merge($rules, [ + 'merchant_id' => 'required|integer|min:0', + 'name' => 'required|string|min:2|max:128', + 'split_rate_bp' => 'required|integer|min:0|max:10000', + 'cost_rate_bp' => 'required|integer|min:0|max:10000', + 'channel_mode' => 'required|integer|in:0,1', + 'pay_type_id' => 'required|integer|min:1|exists:ma_payment_type,id', + 'plugin_code' => 'required|string|min:1|max:64|exists:ma_payment_plugin,code', + 'status' => 'required|integer|in:0,1', + ]), + 'update' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + 'merchant_id' => 'required|integer|min:0', + 'name' => 'required|string|min:2|max:128', + 'split_rate_bp' => 'required|integer|min:0|max:10000', + 'cost_rate_bp' => 'required|integer|min:0|max:10000', + 'channel_mode' => 'required|integer|in:0,1', + 'pay_type_id' => 'required|integer|min:1|exists:ma_payment_type,id', + 'plugin_code' => 'required|string|min:1|max:64|exists:ma_payment_plugin,code', + 'status' => 'required|integer|in:0,1', + ]), + 'updateStatus' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + 'status' => 'required|integer|in:0,1', + ]), + 'show', 'destroy' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + ]), + default => $rules, + }; + } +} diff --git a/app/http/admin/validation/PaymentPluginConfValidator.php b/app/http/admin/validation/PaymentPluginConfValidator.php new file mode 100644 index 0000000..21438ba --- /dev/null +++ b/app/http/admin/validation/PaymentPluginConfValidator.php @@ -0,0 +1,68 @@ + 'sometimes|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'plugin_code' => 'sometimes|string|alpha_dash|min:2|max:32', + 'config' => 'nullable|array', + 'settlement_cycle_type' => 'sometimes|integer|in:0,1,2,3,4', + 'settlement_cutoff_time' => 'nullable|date_format:H:i:s', + 'remark' => 'nullable|string|max:500', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + 'ids' => 'sometimes|array', + ]; + + protected array $attributes = [ + 'id' => '配置ID', + 'keyword' => '关键字', + 'plugin_code' => '插件编码', + 'config' => '插件配置', + 'settlement_cycle_type' => '结算周期', + 'settlement_cutoff_time' => '结算截止时间', + 'remark' => '备注', + 'page' => '页码', + 'page_size' => '每页条数', + 'ids' => '配置ID集合', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'plugin_code', 'page', 'page_size'], + 'store' => ['plugin_code', 'config', 'settlement_cycle_type', 'settlement_cutoff_time', 'remark'], + 'update' => ['id', 'plugin_code', 'config', 'settlement_cycle_type', 'settlement_cutoff_time', 'remark'], + 'show' => ['id'], + 'destroy' => ['id'], + 'options' => ['plugin_code'], + 'selectOptions' => ['keyword', 'plugin_code', 'page', 'page_size', 'ids'], + ]; + + public function rules(): array + { + $rules = parent::rules(); + + return match ($this->scene()) { + 'store' => array_merge($rules, [ + 'plugin_code' => 'required|string|alpha_dash|min:2|max:32', + ]), + 'update' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + 'plugin_code' => 'required|string|alpha_dash|min:2|max:32', + ]), + 'show', 'destroy' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + ]), + default => $rules, + }; + } +} diff --git a/app/http/admin/validation/PaymentPluginValidator.php b/app/http/admin/validation/PaymentPluginValidator.php new file mode 100644 index 0000000..d7995e8 --- /dev/null +++ b/app/http/admin/validation/PaymentPluginValidator.php @@ -0,0 +1,45 @@ + 'sometimes|string|alpha_dash|min:2|max:32', + 'status' => 'sometimes|integer|in:0,1', + 'remark' => 'nullable|string|max:500', + 'keyword' => 'sometimes|string|max:128', + 'name' => 'sometimes|string|max:50', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + 'pay_type_code' => 'sometimes|string|max:32', + 'ids' => 'sometimes|array', + ]; + + protected array $attributes = [ + 'code' => '插件编码', + 'name' => '插件名称', + 'status' => '状态', + 'remark' => '备注', + 'keyword' => '关键字', + 'page' => '页码', + 'page_size' => '每页条数', + 'pay_type_code' => '支付方式编码', + 'ids' => '插件编码集合', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'code', 'name', 'status', 'page', 'page_size'], + 'update' => ['code', 'status', 'remark'], + 'updateStatus' => ['code', 'status'], + 'show' => ['code'], + 'selectOptions' => ['keyword', 'page', 'page_size', 'pay_type_code', 'ids'], + ]; +} diff --git a/app/http/admin/validation/PaymentPollGroupBindValidator.php b/app/http/admin/validation/PaymentPollGroupBindValidator.php new file mode 100644 index 0000000..61de2c5 --- /dev/null +++ b/app/http/admin/validation/PaymentPollGroupBindValidator.php @@ -0,0 +1,70 @@ + 'sometimes|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'merchant_group_id' => 'sometimes|integer|min:1|exists:ma_merchant_group,id', + 'pay_type_id' => 'sometimes|integer|min:1|exists:ma_payment_type,id', + 'poll_group_id' => 'sometimes|integer|min:1|exists:ma_payment_poll_group,id', + 'status' => 'sometimes|integer|in:0,1', + 'remark' => 'nullable|string|max:500', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'id' => '绑定ID', + 'keyword' => '关键字', + 'merchant_group_id' => '商户分组', + 'pay_type_id' => '支付方式', + 'poll_group_id' => '轮询组', + 'status' => '状态', + 'remark' => '备注', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'merchant_group_id', 'pay_type_id', 'poll_group_id', 'status', 'page', 'page_size'], + 'store' => ['merchant_group_id', 'pay_type_id', 'poll_group_id', 'status', 'remark'], + 'update' => ['id', 'merchant_group_id', 'pay_type_id', 'poll_group_id', 'status', 'remark'], + 'show' => ['id'], + 'destroy' => ['id'], + ]; + + public function rules(): array + { + $rules = parent::rules(); + + return match ($this->scene()) { + 'store' => array_merge($rules, [ + 'merchant_group_id' => 'required|integer|min:1|exists:ma_merchant_group,id', + 'pay_type_id' => 'required|integer|min:1|exists:ma_payment_type,id', + 'poll_group_id' => 'required|integer|min:1|exists:ma_payment_poll_group,id', + 'status' => 'required|integer|in:0,1', + ]), + 'update' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + 'merchant_group_id' => 'required|integer|min:1|exists:ma_merchant_group,id', + 'pay_type_id' => 'required|integer|min:1|exists:ma_payment_type,id', + 'poll_group_id' => 'required|integer|min:1|exists:ma_payment_poll_group,id', + 'status' => 'required|integer|in:0,1', + ]), + 'show', 'destroy' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + ]), + default => $rules, + }; + } +} diff --git a/app/http/admin/validation/PaymentPollGroupChannelValidator.php b/app/http/admin/validation/PaymentPollGroupChannelValidator.php new file mode 100644 index 0000000..8b436d8 --- /dev/null +++ b/app/http/admin/validation/PaymentPollGroupChannelValidator.php @@ -0,0 +1,83 @@ + 'sometimes|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'poll_group_id' => 'sometimes|integer|min:1|exists:ma_payment_poll_group,id', + 'channel_id' => 'sometimes|integer|min:1|exists:ma_payment_channel,id', + 'status' => 'sometimes|integer|in:0,1', + 'sort_no' => 'nullable|integer|min:0', + 'weight' => 'nullable|integer|min:1', + 'is_default' => 'sometimes|integer|in:0,1', + 'remark' => 'nullable|string|max:500', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'id' => '编排ID', + 'keyword' => '关键字', + 'poll_group_id' => '轮询组', + 'channel_id' => '支付通道', + 'status' => '状态', + 'sort_no' => '排序', + 'weight' => '权重', + 'is_default' => '默认通道', + 'remark' => '备注', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'poll_group_id', 'channel_id', 'status', 'page', 'page_size'], + 'store' => ['poll_group_id', 'channel_id', 'sort_no', 'weight', 'is_default', 'status', 'remark'], + 'update' => ['id', 'poll_group_id', 'channel_id', 'sort_no', 'weight', 'is_default', 'status', 'remark'], + 'updateStatus' => ['id', 'status'], + 'show' => ['id'], + 'destroy' => ['id'], + ]; + + public function rules(): array + { + $rules = parent::rules(); + + return match ($this->scene()) { + 'store' => array_merge($rules, [ + 'poll_group_id' => 'required|integer|min:1|exists:ma_payment_poll_group,id', + 'channel_id' => 'required|integer|min:1|exists:ma_payment_channel,id', + 'sort_no' => 'required|integer|min:0', + 'weight' => 'required|integer|min:1', + 'is_default' => 'required|integer|in:0,1', + 'status' => 'required|integer|in:0,1', + ]), + 'update' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + 'poll_group_id' => 'required|integer|min:1|exists:ma_payment_poll_group,id', + 'channel_id' => 'required|integer|min:1|exists:ma_payment_channel,id', + 'sort_no' => 'required|integer|min:0', + 'weight' => 'required|integer|min:1', + 'is_default' => 'required|integer|in:0,1', + 'status' => 'required|integer|in:0,1', + ]), + 'updateStatus' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + 'status' => 'required|integer|in:0,1', + ]), + 'show', 'destroy' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + ]), + default => $rules, + }; + } +} diff --git a/app/http/admin/validation/PaymentPollGroupValidator.php b/app/http/admin/validation/PaymentPollGroupValidator.php new file mode 100644 index 0000000..2e5ca70 --- /dev/null +++ b/app/http/admin/validation/PaymentPollGroupValidator.php @@ -0,0 +1,75 @@ + 'sometimes|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'group_name' => 'sometimes|string|min:2|max:128', + 'pay_type_id' => 'sometimes|integer|min:1|exists:ma_payment_type,id', + 'route_mode' => 'sometimes|integer|in:0,1,2', + 'status' => 'sometimes|integer|in:0,1', + 'remark' => 'nullable|string|max:255', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'id' => '轮询组ID', + 'keyword' => '关键字', + 'group_name' => '轮询组名称', + 'pay_type_id' => '支付方式', + 'route_mode' => '路由模式', + 'status' => '状态', + 'remark' => '备注', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'group_name', 'pay_type_id', 'route_mode', 'status', 'page', 'page_size'], + 'store' => ['group_name', 'pay_type_id', 'route_mode', 'status', 'remark'], + 'update' => ['id', 'group_name', 'pay_type_id', 'route_mode', 'status', 'remark'], + 'updateStatus' => ['id', 'status'], + 'show' => ['id'], + 'destroy' => ['id'], + ]; + + public function rules(): array + { + $rules = parent::rules(); + + return match ($this->scene()) { + 'store' => array_merge($rules, [ + 'group_name' => 'required|string|min:2|max:128', + 'pay_type_id' => 'required|integer|min:1|exists:ma_payment_type,id', + 'route_mode' => 'required|integer|in:0,1,2', + 'status' => 'required|integer|in:0,1', + ]), + 'update' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + 'group_name' => 'required|string|min:2|max:128', + 'pay_type_id' => 'required|integer|min:1|exists:ma_payment_type,id', + 'route_mode' => 'required|integer|in:0,1,2', + 'status' => 'required|integer|in:0,1', + ]), + 'updateStatus' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + 'status' => 'required|integer|in:0,1', + ]), + 'show', 'destroy' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + ]), + default => $rules, + }; + } +} diff --git a/app/http/admin/validation/PaymentTypeValidator.php b/app/http/admin/validation/PaymentTypeValidator.php new file mode 100644 index 0000000..709c665 --- /dev/null +++ b/app/http/admin/validation/PaymentTypeValidator.php @@ -0,0 +1,75 @@ + 'sometimes|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'code' => 'sometimes|string|alpha_dash|min:2|max:32', + 'name' => 'sometimes|string|min:2|max:50', + 'icon' => 'nullable|string|max:255', + 'sort_no' => 'nullable|integer|min:0', + 'status' => 'sometimes|integer|in:0,1', + 'remark' => 'nullable|string|max:500', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'id' => '支付方式ID', + 'keyword' => '关键字', + 'code' => '支付方式编码', + 'name' => '支付方式名称', + 'icon' => '图标', + 'sort_no' => '排序', + 'status' => '状态', + 'remark' => '备注', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'code', 'name', 'status', 'page', 'page_size'], + 'store' => ['code', 'name', 'icon', 'sort_no', 'status', 'remark'], + 'update' => ['id', 'code', 'name', 'icon', 'sort_no', 'status', 'remark'], + 'updateStatus' => ['id', 'status'], + 'show' => ['id'], + 'destroy' => ['id'], + ]; + + public function rules(): array + { + $rules = parent::rules(); + + return match ($this->scene()) { + 'store' => array_merge($rules, [ + 'code' => 'required|string|alpha_dash|min:2|max:32', + 'name' => 'required|string|min:2|max:50', + 'status' => 'required|integer|in:0,1', + ]), + 'update' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + 'code' => 'required|string|alpha_dash|min:2|max:32', + 'name' => 'required|string|min:2|max:50', + 'status' => 'required|integer|in:0,1', + ]), + 'updateStatus' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + 'status' => 'required|integer|in:0,1', + ]), + 'show', 'destroy' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + ]), + default => $rules, + }; + } +} diff --git a/app/http/admin/validation/RefundActionValidator.php b/app/http/admin/validation/RefundActionValidator.php new file mode 100644 index 0000000..f73aae3 --- /dev/null +++ b/app/http/admin/validation/RefundActionValidator.php @@ -0,0 +1,36 @@ + 'required|string|max:64', + 'processing_at' => 'sometimes|date_format:Y-m-d H:i:s', + 'failed_at' => 'sometimes|date_format:Y-m-d H:i:s', + 'last_error' => 'sometimes|string|max:512', + 'channel_refund_no' => 'sometimes|string|max:64', + ]; + + protected array $attributes = [ + 'refund_no' => '退款单号', + 'processing_at' => '处理时间', + 'failed_at' => '失败时间', + 'last_error' => '错误信息', + 'channel_refund_no' => '渠道退款单号', + ]; + + protected array $scenes = [ + 'retry' => ['refund_no', 'processing_at'], + 'mark_fail' => ['refund_no', 'failed_at', 'last_error'], + 'mark_processing' => ['refund_no', 'processing_at'], + 'mark_success' => ['refund_no', 'channel_refund_no'], + ]; +} diff --git a/app/http/admin/validation/RefundOrderValidator.php b/app/http/admin/validation/RefundOrderValidator.php new file mode 100644 index 0000000..a676959 --- /dev/null +++ b/app/http/admin/validation/RefundOrderValidator.php @@ -0,0 +1,37 @@ + 'sometimes|string|max:128', + 'merchant_id' => 'sometimes|integer|min:1', + 'pay_type_id' => 'sometimes|integer|min:1', + 'status' => 'sometimes|integer|in:0,1,2,3,4', + 'channel_mode' => 'sometimes|integer|in:0,1', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'keyword' => '关键字', + 'merchant_id' => '商户ID', + 'pay_type_id' => '支付方式', + 'status' => '退款状态', + 'channel_mode' => '通道模式', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'merchant_id', 'pay_type_id', 'status', 'channel_mode', 'page', 'page_size'], + ]; +} diff --git a/app/http/admin/validation/RouteResolveValidator.php b/app/http/admin/validation/RouteResolveValidator.php new file mode 100644 index 0000000..abf7d76 --- /dev/null +++ b/app/http/admin/validation/RouteResolveValidator.php @@ -0,0 +1,35 @@ + 'required|integer|min:1', + 'pay_type_id' => 'required|integer|min:1', + 'pay_amount' => 'required|integer|min:1', + 'pay_type_code' => 'sometimes|string|max:32', + 'channel_mode' => 'sometimes|integer|in:0,1', + 'stat_date' => 'sometimes|date_format:Y-m-d', + ]; + + protected array $attributes = [ + 'merchant_group_id' => '商户分组', + 'pay_type_id' => '支付方式', + 'pay_amount' => '支付金额', + 'pay_type_code' => '支付方式编码', + 'channel_mode' => '通道模式', + 'stat_date' => '统计日期', + ]; + + protected array $scenes = [ + 'resolve' => ['merchant_group_id', 'pay_type_id', 'pay_amount', 'pay_type_code', 'channel_mode', 'stat_date'], + ]; +} diff --git a/app/http/admin/validation/SettlementOrderValidator.php b/app/http/admin/validation/SettlementOrderValidator.php new file mode 100644 index 0000000..e008dc2 --- /dev/null +++ b/app/http/admin/validation/SettlementOrderValidator.php @@ -0,0 +1,38 @@ + 'required|string|max:32', + 'keyword' => 'sometimes|string|max:128', + 'merchant_id' => 'sometimes|integer|min:1', + 'channel_id' => 'sometimes|integer|min:1', + 'status' => 'sometimes|integer|min:0', + 'cycle_type' => 'sometimes|integer|min:0', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'settle_no' => '清算单号', + 'keyword' => '关键词', + 'merchant_id' => '所属商户', + 'channel_id' => '所属通道', + 'status' => '状态', + 'cycle_type' => '结算周期类型', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'merchant_id', 'channel_id', 'status', 'cycle_type', 'page', 'page_size'], + 'show' => ['settle_no'], + ]; +} diff --git a/app/http/admin/validation/SystemConfigPageValidator.php b/app/http/admin/validation/SystemConfigPageValidator.php new file mode 100644 index 0000000..3f79f9f --- /dev/null +++ b/app/http/admin/validation/SystemConfigPageValidator.php @@ -0,0 +1,23 @@ + 'required|string|min:1|max:50|regex:/^[a-z0-9_]+$/', + 'values' => 'required|array', + ]; + + protected array $attributes = [ + 'group_code' => '配置分组', + 'values' => '配置值', + ]; + + protected array $scenes = [ + 'show' => ['group_code'], + 'store' => ['group_code', 'values'], + ]; +} diff --git a/app/http/api/controller/EpayController.php b/app/http/api/controller/EpayController.php deleted file mode 100644 index a088ce2..0000000 --- a/app/http/api/controller/EpayController.php +++ /dev/null @@ -1,98 +0,0 @@ -epayProtocolService->handleSubmit($request); - $type = $result['response_type'] ?? ''; - - if ($type === 'redirect' && !empty($result['url'])) { - return redirect($result['url']); - } - - if ($type === 'form_html') { - return response((string)($result['html'] ?? '')) - ->withHeaders(['Content-Type' => 'text/html; charset=UTF-8']); - } - - if ($type === 'form_params') { - return $this->renderForm((array)($result['form'] ?? [])); - } - - return $this->fail('支付参数生成失败'); - } catch (\Throwable $e) { - return $this->fail($e->getMessage()); - } - } - - /** - * API接口支付 - */ - public function mapi(Request $request) - { - try { - return json($this->epayProtocolService->handleMapi($request)); - } catch (\Throwable $e) { - return json([ - 'code' => 0, - 'msg' => $e->getMessage(), - ]); - } - } - - /** - * API接口 - */ - public function api(Request $request) - { - try { - return json($this->epayProtocolService->handleApi($request)); - } catch (\Throwable $e) { - return json([ - 'code' => 0, - 'msg' => $e->getMessage(), - ]); - } - } - - /** - * 渲染表单提交 HTML(用于页面跳转支付) - */ - private function renderForm(array $formParams): Response - { - $html = '跳转支付'; - $html .= '
'; - - if (isset($formParams['fields']) && is_array($formParams['fields'])) { - foreach ($formParams['fields'] as $name => $value) { - $html .= ''; - } - } - - $html .= '
'; - $html .= ''; - $html .= ''; - - return response($html)->withHeaders(['Content-Type' => 'text/html; charset=UTF-8']); - } -} diff --git a/app/http/api/controller/PayController.php b/app/http/api/controller/PayController.php deleted file mode 100644 index 35670cf..0000000 --- a/app/http/api/controller/PayController.php +++ /dev/null @@ -1,74 +0,0 @@ -fail('not implemented', 501); - } - - /** - * 查询订单 - */ - public function query(Request $request) - { - return $this->fail('not implemented', 501); - } - - /** - * 关闭订单 - */ - public function close(Request $request) - { - return $this->fail('not implemented', 501); - } - - /** - * 订单退款 - */ - public function refund(Request $request) - { - return $this->fail('not implemented', 501); - } - - /** - * 异步通知 - */ - public function notify(Request $request, string $pluginCode) - { - try { - $plugin = $this->pluginService->getPluginInstance($pluginCode); - $result = $this->payNotifyService->handleNotify($pluginCode, $request); - $ackSuccess = method_exists($plugin, 'notifySuccess') ? $plugin->notifySuccess() : 'success'; - $ackFail = method_exists($plugin, 'notifyFail') ? $plugin->notifyFail() : 'fail'; - - if (!($result['ok'] ?? false)) { - return $ackFail instanceof Response ? $ackFail : response((string)$ackFail); - } - - return $ackSuccess instanceof Response ? $ackSuccess : response((string)$ackSuccess); - } catch (\Throwable $e) { - return response('fail'); - } - } -} diff --git a/app/http/api/controller/adapter/EpayController.php b/app/http/api/controller/adapter/EpayController.php new file mode 100644 index 0000000..57a041f --- /dev/null +++ b/app/http/api/controller/adapter/EpayController.php @@ -0,0 +1,100 @@ +validated($request->all(), EpayValidator::class, 'submit'); + + return $this->epayCompatService->submit($payload, $request); + } catch (ValidationException $e) { + return json([ + 'code' => 0, + 'msg' => $e->getMessage(), + ]); + } catch (\Throwable $e) { + return json([ + 'code' => 0, + 'msg' => $e->getMessage() ?: '提交失败', + ]); + } + } + + /** + * API 接口支付入口。 + */ + public function mapi(Request $request): Response + { + try { + $payload = $this->validated($request->all(), EpayValidator::class, 'mapi'); + + return json($this->epayCompatService->mapi($payload, $request)); + } catch (ValidationException $e) { + return json([ + 'code' => 0, + 'msg' => $e->getMessage(), + ]); + } catch (\Throwable $e) { + return json([ + 'code' => 0, + 'msg' => $e->getMessage() ?: '提交失败', + ]); + } + } + + /** + * 标准 API 接口入口。 + */ + public function api(Request $request): Response + { + try { + $payload = $request->all(); + $act = strtolower(trim((string) ($payload['act'] ?? ''))); + $scene = match ($act) { + 'settle' => 'settle', + 'orders' => 'orders', + 'order' => trim((string) ($payload['trade_no'] ?? '')) !== '' ? 'order_trade_no' : 'order_out_trade_no', + 'refund' => trim((string) ($payload['trade_no'] ?? '')) !== '' ? 'refund_trade_no' : 'refund_out_trade_no', + default => 'query', + }; + $payload = $this->validated($payload, EpayValidator::class, $scene); + + return json($this->epayCompatService->api($payload)); + } catch (ValidationException $e) { + return json([ + 'code' => 0, + 'msg' => $e->getMessage(), + ]); + } catch (\Throwable $e) { + return json([ + 'code' => 0, + 'msg' => $e->getMessage() ?: '请求失败', + ]); + } + } +} diff --git a/app/http/api/controller/notify/NotifyController.php b/app/http/api/controller/notify/NotifyController.php new file mode 100644 index 0000000..52a4338 --- /dev/null +++ b/app/http/api/controller/notify/NotifyController.php @@ -0,0 +1,51 @@ +validated($request->all(), NotifyChannelValidator::class, 'store'); + + return $this->success($this->notifyService->recordChannelNotify($data)); + } + + /** + * POST /api/notify/merchant + * + * 创建商户通知任务。 + */ + public function merchant(Request $request): Response + { + $data = $this->validated($request->all(), NotifyMerchantValidator::class, 'store'); + + return $this->success($this->notifyService->enqueueMerchantNotify($data)); + } +} + diff --git a/app/http/api/controller/route/RouteController.php b/app/http/api/controller/route/RouteController.php new file mode 100644 index 0000000..84c3a0a --- /dev/null +++ b/app/http/api/controller/route/RouteController.php @@ -0,0 +1,43 @@ +validated($request->all(), RouteResolveValidator::class, 'resolve'); + + return $this->success($this->paymentRouteService->resolveByMerchantGroup( + (int) $data['merchant_group_id'], + (int) $data['pay_type_id'], + (int) $data['pay_amount'], + $data + )); + } +} + diff --git a/app/http/api/controller/settlement/SettlementController.php b/app/http/api/controller/settlement/SettlementController.php new file mode 100644 index 0000000..6ddc3d7 --- /dev/null +++ b/app/http/api/controller/settlement/SettlementController.php @@ -0,0 +1,78 @@ +validated($request->all(), SettlementCreateValidator::class, 'store'); + $items = (array) ($data['items'] ?? []); + + return $this->success($this->settlementService->createSettlementOrder($data, $items)); + } + + /** + * 查询清结算单详情。 + */ + public function show(Request $request, string $settleNo): Response + { + try { + return $this->success($this->settlementService->detail($settleNo)); + } catch (ResourceNotFoundException) { + return $this->fail('清算单不存在', 404); + } + } + + /** + * 标记清结算成功。 + */ + public function complete(Request $request, string $settleNo): Response + { + $this->validated( + array_merge($request->all(), ['settle_no' => $settleNo]), + SettlementActionValidator::class, + 'complete' + ); + + return $this->success($this->settlementService->completeSettlement($settleNo)); + } + + /** + * 标记清结算失败。 + */ + public function failSettlement(Request $request, string $settleNo): Response + { + $data = $this->validated( + array_merge($request->all(), ['settle_no' => $settleNo]), + SettlementActionValidator::class, + 'fail' + ); + + return $this->success($this->settlementService->failSettlement($settleNo, (string) ($data['reason'] ?? ''))); + } +} diff --git a/app/http/api/controller/trace/TraceController.php b/app/http/api/controller/trace/TraceController.php new file mode 100644 index 0000000..9377d06 --- /dev/null +++ b/app/http/api/controller/trace/TraceController.php @@ -0,0 +1,39 @@ +validated( + array_merge($request->all(), ['trace_no' => $traceNo]), + TraceQueryValidator::class, + 'show' + ); + + $result = $this->tradeTraceService->queryByTraceNo((string) $data['trace_no']); + if (empty($result)) { + return $this->fail('追踪单不存在', 404); + } + + return $this->success($result); + } +} diff --git a/app/http/api/controller/trade/PayController.php b/app/http/api/controller/trade/PayController.php new file mode 100644 index 0000000..3d240ec --- /dev/null +++ b/app/http/api/controller/trade/PayController.php @@ -0,0 +1,138 @@ +validated( + $this->normalizePreparePayload($request, $request->all()), + PayPrepareValidator::class, + 'prepare' + ); + + return $this->success($this->payOrderService->preparePayAttempt($data)); + } + + /** + * 查询支付单详情。 + */ + public function show(Request $request, string $payNo): Response + { + try { + return $this->success($this->payOrderService->detail($payNo)); + } catch (ResourceNotFoundException) { + return $this->fail('支付单不存在', 404); + } + } + + /** + * 关闭支付单。 + */ + public function close(Request $request, string $payNo): Response + { + $data = $this->validated( + array_merge($request->all(), ['pay_no' => $payNo]), + PayCloseValidator::class, + 'close' + ); + + return $this->success($this->payOrderService->closePayOrder($payNo, $data)); + } + + /** + * 标记支付单超时。 + */ + public function timeout(Request $request, string $payNo): Response + { + $data = $this->validated( + array_merge($request->all(), ['pay_no' => $payNo]), + PayTimeoutValidator::class, + 'timeout' + ); + + return $this->success($this->payOrderService->timeoutPayOrder($payNo, $data)); + } + + /** + * 处理渠道回调。 + */ + public function callback(Request $request, string $payNo = ''): Response|string + { + if ($payNo !== '') { + return $this->payOrderService->handlePluginCallback($payNo, $request); + } + + $data = $this->validated($request->all(), PayCallbackValidator::class, 'callback'); + + return $this->success($this->payOrderService->handleChannelCallback($data)); + } + + /** + * 归一化外部支付下单参数并完成签名校验。 + * + * 这层逻辑保留在控制器内,避免中间件承担业务验签职责。 + */ + private function normalizePreparePayload(Request $request, array $payload): array + { + $this->merchantApiCredentialService->verifyMd5Sign($payload); + + $typeCode = trim((string) ($payload['type'] ?? '')); + $paymentType = $this->paymentTypeService->resolveEnabledType($typeCode); + $typeCode = (string) $paymentType->code; + + $money = (string) ($payload['money'] ?? '0'); + $amount = (int) round(((float) $money) * 100); + + return [ + 'merchant_id' => (int) ($payload['pid'] ?? 0), + 'merchant_order_no' => (string) ($payload['out_trade_no'] ?? ''), + 'pay_type_id' => (int) $paymentType->id, + 'pay_amount' => $amount, + 'subject' => (string) ($payload['name'] ?? ''), + 'body' => (string) ($payload['name'] ?? ''), + 'ext_json' => [ + 'type_code' => $typeCode, + 'notify_url' => (string) ($payload['notify_url'] ?? ''), + 'return_url' => (string) ($payload['return_url'] ?? ''), + 'param' => $payload['param'] ?? null, + 'clientip' => (string) ($payload['clientip'] ?? ''), + 'device' => (string) ($payload['device'] ?? ''), + 'sign_type' => (string) ($payload['sign_type'] ?? 'MD5'), + 'channel_callback_base_url' => (string) sys_config('site_url') . '/api/pay', + ], + ]; + } +} + diff --git a/app/http/api/controller/trade/RefundController.php b/app/http/api/controller/trade/RefundController.php new file mode 100644 index 0000000..86d4b17 --- /dev/null +++ b/app/http/api/controller/trade/RefundController.php @@ -0,0 +1,93 @@ +validated($request->all(), RefundCreateValidator::class, 'store'); + + return $this->success($this->refundService->createRefund($data)); + } + + /** + * 查询退款单详情。 + */ + public function show(Request $request, string $refundNo): Response + { + try { + return $this->success($this->refundService->detail($refundNo)); + } catch (ResourceNotFoundException) { + return $this->fail('退款单不存在', 404); + } + } + + /** + * 标记退款处理中。 + */ + public function processing(Request $request, string $refundNo): Response + { + $data = $this->validated( + array_merge($request->all(), ['refund_no' => $refundNo]), + RefundActionValidator::class, + 'processing' + ); + + return $this->success($this->refundService->markRefundProcessing($refundNo, $data)); + } + + /** + * 退款重试。 + */ + public function retry(Request $request, string $refundNo): Response + { + $data = $this->validated( + array_merge($request->all(), ['refund_no' => $refundNo]), + RefundActionValidator::class, + 'retry' + ); + + return $this->success($this->refundService->retryRefund($refundNo, $data)); + } + + /** + * 标记退款失败。 + */ + public function markFail(Request $request, string $refundNo): Response + { + $data = $this->validated( + array_merge($request->all(), ['refund_no' => $refundNo]), + RefundActionValidator::class, + 'fail' + ); + + return $this->success($this->refundService->markRefundFailed($refundNo, $data)); + } + +} + diff --git a/app/http/api/middleware/EpayAuthMiddleware.php b/app/http/api/middleware/EpayAuthMiddleware.php deleted file mode 100644 index 2609b90..0000000 --- a/app/http/api/middleware/EpayAuthMiddleware.php +++ /dev/null @@ -1,69 +0,0 @@ -merchantAppRepository = new MerchantAppRepository(); - } - - public function process(Request $request, callable $handler): Response - { - $appId = $request->header('X-App-Id', '') ?: ($request->post('app_id', '') ?: $request->get('app_id', '')); - $timestamp = $request->header('X-Timestamp', '') ?: ($request->post('timestamp', '') ?: $request->get('timestamp', '')); - $nonce = $request->header('X-Nonce', '') ?: ($request->post('nonce', '') ?: $request->get('nonce', '')); - $signature = $request->header('X-Signature', '') ?: ($request->post('signature', '') ?: $request->get('signature', '')); - - if (empty($appId) || empty($timestamp) || empty($nonce) || empty($signature)) { - throw new UnauthorizedException('缺少认证参数'); - } - - // 验证时间戳(5分钟内有效) - if (abs(time() - (int)$timestamp) > 300) { - throw new UnauthorizedException('请求已过期'); - } - - // 查询应用 - $app = $this->merchantAppRepository->findByAppId($appId); - if (!$app) { - throw new UnauthorizedException('应用不存在或已禁用'); - } - - // 验证签名 - $method = $request->method(); - $path = $request->path(); - $body = $request->rawBody(); - $bodySha256 = hash('sha256', $body); - - $signString = "app_id={$appId}×tamp={$timestamp}&nonce={$nonce}&method={$method}&path={$path}&body_sha256={$bodySha256}"; - $expectedSignature = hash_hmac('sha256', $signString, $app->app_secret); - - if (!hash_equals($expectedSignature, $signature)) { - throw new UnauthorizedException('签名验证失败'); - } - - // 将应用信息注入到请求对象 - $request->app = $app; - $request->merchantId = $app->merchant_id; - $request->appId = $app->id; - - return $handler($request); - } -} - diff --git a/app/http/api/middleware/OpenApiAuthMiddleware.php b/app/http/api/middleware/OpenApiAuthMiddleware.php deleted file mode 100644 index 14cd5e1..0000000 --- a/app/http/api/middleware/OpenApiAuthMiddleware.php +++ /dev/null @@ -1,69 +0,0 @@ -merchantAppRepository = new MerchantAppRepository(); - } - - public function process(Request $request, callable $handler): Response - { - $appId = $request->header('X-App-Id', '') ?: ($request->post('app_id', '') ?: $request->get('app_id', '')); - $timestamp = $request->header('X-Timestamp', '') ?: ($request->post('timestamp', '') ?: $request->get('timestamp', '')); - $nonce = $request->header('X-Nonce', '') ?: ($request->post('nonce', '') ?: $request->get('nonce', '')); - $signature = $request->header('X-Signature', '') ?: ($request->post('signature', '') ?: $request->get('signature', '')); - - if (empty($appId) || empty($timestamp) || empty($nonce) || empty($signature)) { - throw new UnauthorizedException('缺少认证参数'); - } - - // 验证时间戳(5分钟内有效) - if (abs(time() - (int)$timestamp) > 300) { - throw new UnauthorizedException('请求已过期'); - } - - // 查询应用 - $app = $this->merchantAppRepository->findByAppId($appId); - if (!$app) { - throw new UnauthorizedException('应用不存在或已禁用'); - } - - // 验证签名 - $method = $request->method(); - $path = $request->path(); - $body = $request->rawBody(); - $bodySha256 = hash('sha256', $body); - - $signString = "app_id={$appId}×tamp={$timestamp}&nonce={$nonce}&method={$method}&path={$path}&body_sha256={$bodySha256}"; - $expectedSignature = hash_hmac('sha256', $signString, $app->app_secret); - - if (!hash_equals($expectedSignature, $signature)) { - throw new UnauthorizedException('签名验证失败'); - } - - // 将应用信息注入到请求对象 - $request->app = $app; - $request->merchantId = $app->merchant_id; - $request->appId = $app->id; - - return $handler($request); - } -} - diff --git a/app/http/api/validation/EpayValidator.php b/app/http/api/validation/EpayValidator.php new file mode 100644 index 0000000..a9245cd --- /dev/null +++ b/app/http/api/validation/EpayValidator.php @@ -0,0 +1,113 @@ + 'required|string|in:query,settle,order,orders,refund', + 'pid' => 'required|integer|gt:0', + 'key' => 'required|string|min:1|max:255', + 'type' => 'sometimes|string|max:32', + 'out_trade_no' => 'sometimes|string|min:1|max:64', + 'notify_url' => 'sometimes|url|max:255', + 'return_url' => 'sometimes|url|max:255', + 'name' => 'sometimes|string|min:1|max:255', + 'money' => 'sometimes|numeric|gt:0|regex:/^\d+(?:\.\d{1,2})?$/', + 'sign' => 'sometimes|string|min:1|max:255', + 'sign_type' => 'sometimes|string|in:MD5,md5', + 'device' => 'sometimes|string|in:pc,mobile,qq,wechat,alipay,jump', + 'clientip' => 'sometimes|ip', + 'param' => 'sometimes|string|max:2000', + 'trade_no' => 'sometimes|string|min:1|max:64', + 'refund_no' => 'sometimes|string|min:1|max:64', + 'reason' => 'sometimes|string|max:255', + 'limit' => 'sometimes|integer|gt:0|max:50', + 'page' => 'sometimes|integer|gt:0', + ]; + + protected array $attributes = [ + 'act' => '操作类型', + 'pid' => '商户ID', + 'key' => '商户密钥', + 'type' => '支付方式', + 'out_trade_no' => '商户订单号', + 'trade_no' => '易支付订单号', + 'notify_url' => '异步通知地址', + 'return_url' => '跳转通知地址', + 'name' => '商品名称', + 'money' => '商品金额', + 'sign' => '签名字符串', + 'sign_type' => '签名类型', + 'device' => '设备类型', + 'clientip' => '用户IP地址', + 'param' => '业务扩展参数', + 'refund_no' => '退款单号', + 'reason' => '退款原因', + 'limit' => '查询订单数量', + 'page' => '页码', + ]; + + protected array $scenes = [ + 'submit' => ['pid', 'type', 'out_trade_no', 'notify_url', 'return_url', 'name', 'money', 'sign', 'sign_type', 'param'], + 'mapi' => ['pid', 'type', 'out_trade_no', 'notify_url', 'return_url', 'name', 'money', 'clientip', 'device', 'sign', 'sign_type', 'param'], + 'query' => ['act', 'pid', 'key'], + 'settle' => ['act', 'pid', 'key'], + 'order_trade_no' => ['act', 'pid', 'key', 'trade_no'], + 'order_out_trade_no' => ['act', 'pid', 'key', 'out_trade_no'], + 'orders' => ['act', 'pid', 'key', 'limit', 'page'], + 'refund_trade_no' => ['act', 'pid', 'key', 'trade_no', 'money', 'refund_no', 'reason'], + 'refund_out_trade_no' => ['act', 'pid', 'key', 'out_trade_no', 'money', 'refund_no', 'reason'], + ]; + + public function rules(): array + { + $rules = parent::rules(); + + return match ($this->scene()) { + 'submit' => array_merge($rules, [ + 'type' => 'sometimes|string|max:32', + 'out_trade_no' => 'required|string|min:1|max:64', + 'notify_url' => 'required|url|max:255', + 'return_url' => 'required|url|max:255', + 'name' => 'required|string|min:1|max:255', + 'money' => 'required|numeric|gt:0|regex:/^\d+(?:\.\d{1,2})?$/', + 'sign' => 'required|string|min:1|max:255', + 'sign_type' => 'required|string|in:MD5,md5', + ]), + 'mapi' => array_merge($rules, [ + 'type' => 'required|string|max:32', + 'out_trade_no' => 'required|string|min:1|max:64', + 'notify_url' => 'required|url|max:255', + 'return_url' => 'sometimes|url|max:255', + 'name' => 'required|string|min:1|max:255', + 'money' => 'required|numeric|gt:0|regex:/^\d+(?:\.\d{1,2})?$/', + 'clientip' => 'required|ip', + 'sign' => 'required|string|min:1|max:255', + 'sign_type' => 'required|string|in:MD5,md5', + ]), + 'order_trade_no' => array_merge($rules, [ + 'trade_no' => 'required|string|min:1|max:64', + ]), + 'order_out_trade_no' => array_merge($rules, [ + 'out_trade_no' => 'required|string|min:1|max:64', + ]), + 'refund_trade_no' => array_merge($rules, [ + 'trade_no' => 'required|string|min:1|max:64', + 'money' => 'required|numeric|gt:0|regex:/^\d+(?:\.\d{1,2})?$/', + ]), + 'refund_out_trade_no' => array_merge($rules, [ + 'out_trade_no' => 'required|string|min:1|max:64', + 'money' => 'required|numeric|gt:0|regex:/^\d+(?:\.\d{1,2})?$/', + ]), + default => $rules, + }; + } +} diff --git a/app/http/api/validation/NotifyChannelValidator.php b/app/http/api/validation/NotifyChannelValidator.php new file mode 100644 index 0000000..f96ed6f --- /dev/null +++ b/app/http/api/validation/NotifyChannelValidator.php @@ -0,0 +1,49 @@ + 'sometimes|string|min:1|max:64', + 'channel_id' => 'required|integer|min:1|exists:ma_payment_channel,id', + 'notify_type' => 'sometimes|integer|in:0,1', + 'biz_no' => 'required|string|min:1|max:64', + 'pay_no' => 'sometimes|string|min:1|max:64', + 'channel_request_no' => 'sometimes|string|min:1|max:64', + 'channel_trade_no' => 'sometimes|string|min:1|max:64', + 'raw_payload' => 'nullable|array', + 'verify_status' => 'sometimes|integer|in:0,1,2', + 'process_status' => 'sometimes|integer|in:0,1,2', + 'retry_count' => 'sometimes|integer|min:0', + 'next_retry_at' => 'nullable|date_format:Y-m-d H:i:s', + 'last_error' => 'nullable|string|max:255', + ]; + + protected array $attributes = [ + 'notify_no' => '通知单号', + 'channel_id' => '通道ID', + 'notify_type' => '通知类型', + 'biz_no' => '业务单号', + 'pay_no' => '支付单号', + 'channel_request_no' => '渠道请求号', + 'channel_trade_no' => '渠道交易号', + 'raw_payload' => '原始通知数据', + 'verify_status' => '验签状态', + 'process_status' => '处理状态', + 'retry_count' => '重试次数', + 'next_retry_at' => '下次重试时间', + 'last_error' => '最后错误', + ]; + + protected array $scenes = [ + 'store' => ['notify_no', 'channel_id', 'notify_type', 'biz_no', 'pay_no', 'channel_request_no', 'channel_trade_no', 'raw_payload', 'verify_status', 'process_status', 'retry_count', 'next_retry_at', 'last_error'], + ]; +} diff --git a/app/http/api/validation/NotifyMerchantValidator.php b/app/http/api/validation/NotifyMerchantValidator.php new file mode 100644 index 0000000..66bb4ed --- /dev/null +++ b/app/http/api/validation/NotifyMerchantValidator.php @@ -0,0 +1,47 @@ + 'sometimes|string|min:1|max:64', + 'merchant_id' => 'required|integer|min:1|exists:ma_merchant,id', + 'merchant_group_id' => 'required|integer|min:1|exists:ma_merchant_group,id', + 'biz_no' => 'required|string|min:1|max:64', + 'pay_no' => 'sometimes|string|min:1|max:64', + 'notify_url' => 'required|url|max:255', + 'notify_data' => 'nullable|array', + 'status' => 'sometimes|integer|min:0', + 'retry_count' => 'sometimes|integer|min:0', + 'next_retry_at' => 'nullable|date_format:Y-m-d H:i:s', + 'last_notify_at' => 'nullable|date_format:Y-m-d H:i:s', + 'last_response' => 'nullable|string|max:255', + ]; + + protected array $attributes = [ + 'notify_no' => '通知单号', + 'merchant_id' => '商户ID', + 'merchant_group_id' => '商户分组ID', + 'biz_no' => '业务单号', + 'pay_no' => '支付单号', + 'notify_url' => '通知地址', + 'notify_data' => '通知内容', + 'status' => '状态', + 'retry_count' => '重试次数', + 'next_retry_at' => '下次重试时间', + 'last_notify_at' => '最后通知时间', + 'last_response' => '最后响应', + ]; + + protected array $scenes = [ + 'store' => ['notify_no', 'merchant_id', 'merchant_group_id', 'biz_no', 'pay_no', 'notify_url', 'notify_data', 'status', 'retry_count', 'next_retry_at', 'last_notify_at', 'last_response'], + ]; +} diff --git a/app/http/api/validation/PayCallbackValidator.php b/app/http/api/validation/PayCallbackValidator.php new file mode 100644 index 0000000..b484892 --- /dev/null +++ b/app/http/api/validation/PayCallbackValidator.php @@ -0,0 +1,53 @@ + 'required|string|min:1|max:64|exists:ma_pay_order,pay_no', + 'success' => 'required|boolean', + 'channel_id' => 'nullable|integer|min:1|exists:ma_payment_channel,id', + 'callback_type' => 'nullable|integer|in:0,1', + 'request_data' => 'nullable|array', + 'verify_status' => 'nullable|integer|in:0,1,2', + 'process_status' => 'nullable|integer|in:0,1,2', + 'process_result' => 'nullable|array', + 'channel_trade_no' => 'nullable|string|max:64', + 'channel_order_no' => 'nullable|string|max:64', + 'fee_actual_amount' => 'nullable|integer|min:0', + 'paid_at' => 'nullable|date_format:Y-m-d H:i:s', + 'channel_error_code' => 'nullable|string|max:64', + 'channel_error_msg' => 'nullable|string|max:255', + 'ext_json' => 'nullable|array', + ]; + + protected array $attributes = [ + 'pay_no' => '支付单号', + 'success' => '是否成功', + 'channel_id' => '通道ID', + 'callback_type' => '回调类型', + 'request_data' => '原始回调数据', + 'verify_status' => '验签状态', + 'process_status' => '处理状态', + 'process_result' => '处理结果', + 'channel_trade_no' => '渠道交易号', + 'channel_order_no' => '渠道订单号', + 'fee_actual_amount' => '实际手续费', + 'paid_at' => '支付时间', + 'channel_error_code' => '错误码', + 'channel_error_msg' => '错误信息', + 'ext_json' => '扩展信息', + ]; + + protected array $scenes = [ + 'callback' => ['pay_no', 'success', 'channel_id', 'callback_type', 'request_data', 'verify_status', 'process_status', 'process_result', 'channel_trade_no', 'channel_order_no', 'fee_actual_amount', 'paid_at', 'channel_error_code', 'channel_error_msg', 'ext_json'], + ]; +} diff --git a/app/http/api/validation/PayCloseValidator.php b/app/http/api/validation/PayCloseValidator.php new file mode 100644 index 0000000..81a02da --- /dev/null +++ b/app/http/api/validation/PayCloseValidator.php @@ -0,0 +1,31 @@ + 'required|string|min:1|max:64|exists:ma_pay_order,pay_no', + 'reason' => 'nullable|string|max:255', + 'closed_at' => 'nullable|date_format:Y-m-d H:i:s', + 'ext_json' => 'nullable|array', + ]; + + protected array $attributes = [ + 'pay_no' => '支付单号', + 'reason' => '关闭原因', + 'closed_at' => '关闭时间', + 'ext_json' => '扩展信息', + ]; + + protected array $scenes = [ + 'close' => ['pay_no', 'reason', 'closed_at', 'ext_json'], + ]; +} diff --git a/app/http/api/validation/PayPrepareValidator.php b/app/http/api/validation/PayPrepareValidator.php new file mode 100644 index 0000000..24ac9fb --- /dev/null +++ b/app/http/api/validation/PayPrepareValidator.php @@ -0,0 +1,37 @@ + 'required|integer|min:1|exists:ma_merchant,id', + 'merchant_order_no' => 'required|string|min:1|max:64', + 'pay_type_id' => 'required|integer|min:1|exists:ma_payment_type,id', + 'pay_amount' => 'required|integer|min:1', + 'subject' => 'sometimes|string|max:255', + 'body' => 'sometimes|string|max:500', + 'ext_json' => 'nullable|array', + ]; + + protected array $attributes = [ + 'merchant_id' => '商户ID', + 'merchant_order_no' => '商户订单号', + 'pay_type_id' => '支付方式', + 'pay_amount' => '支付金额', + 'subject' => '标题', + 'body' => '描述', + 'ext_json' => '扩展信息', + ]; + + protected array $scenes = [ + 'prepare' => ['merchant_id', 'merchant_order_no', 'pay_type_id', 'pay_amount', 'subject', 'body', 'ext_json'], + ]; +} diff --git a/app/http/api/validation/PayTimeoutValidator.php b/app/http/api/validation/PayTimeoutValidator.php new file mode 100644 index 0000000..99f50b3 --- /dev/null +++ b/app/http/api/validation/PayTimeoutValidator.php @@ -0,0 +1,31 @@ + 'required|string|min:1|max:64|exists:ma_pay_order,pay_no', + 'reason' => 'nullable|string|max:255', + 'timeout_at' => 'nullable|date_format:Y-m-d H:i:s', + 'ext_json' => 'nullable|array', + ]; + + protected array $attributes = [ + 'pay_no' => '支付单号', + 'reason' => '超时原因', + 'timeout_at' => '超时时间', + 'ext_json' => '扩展信息', + ]; + + protected array $scenes = [ + 'timeout' => ['pay_no', 'reason', 'timeout_at', 'ext_json'], + ]; +} diff --git a/app/http/api/validation/RefundActionValidator.php b/app/http/api/validation/RefundActionValidator.php new file mode 100644 index 0000000..8e550c0 --- /dev/null +++ b/app/http/api/validation/RefundActionValidator.php @@ -0,0 +1,37 @@ + 'required|string|min:1|max:64|exists:ma_refund_order,refund_no', + 'reason' => 'nullable|string|max:255', + 'last_error' => 'nullable|string|max:255', + 'processing_at' => 'nullable|date_format:Y-m-d H:i:s', + 'failed_at' => 'nullable|date_format:Y-m-d H:i:s', + 'ext_json' => 'nullable|array', + ]; + + protected array $attributes = [ + 'refund_no' => '退款单号', + 'reason' => '原因', + 'last_error' => '最近错误信息', + 'processing_at' => '处理时间', + 'failed_at' => '失败时间', + 'ext_json' => '扩展信息', + ]; + + protected array $scenes = [ + 'processing' => ['refund_no', 'reason', 'last_error', 'processing_at', 'ext_json'], + 'retry' => ['refund_no', 'reason', 'last_error', 'processing_at', 'ext_json'], + 'fail' => ['refund_no', 'reason', 'last_error', 'failed_at', 'ext_json'], + ]; +} diff --git a/app/http/api/validation/RefundCreateValidator.php b/app/http/api/validation/RefundCreateValidator.php new file mode 100644 index 0000000..a8d408b --- /dev/null +++ b/app/http/api/validation/RefundCreateValidator.php @@ -0,0 +1,33 @@ + 'required|string|min:1|max:64|exists:ma_pay_order,pay_no', + 'merchant_refund_no' => 'sometimes|string|min:1|max:64', + 'refund_amount' => 'nullable|integer|min:1', + 'reason' => 'nullable|string|max:255', + 'ext_json' => 'nullable|array', + ]; + + protected array $attributes = [ + 'pay_no' => '支付单号', + 'merchant_refund_no' => '商户退款单号', + 'refund_amount' => '退款金额', + 'reason' => '退款原因', + 'ext_json' => '扩展信息', + ]; + + protected array $scenes = [ + 'store' => ['pay_no', 'merchant_refund_no', 'refund_amount', 'reason', 'ext_json'], + ]; +} diff --git a/app/http/api/validation/RouteResolveValidator.php b/app/http/api/validation/RouteResolveValidator.php new file mode 100644 index 0000000..b82d2fc --- /dev/null +++ b/app/http/api/validation/RouteResolveValidator.php @@ -0,0 +1,31 @@ + 'required|integer|min:1|exists:ma_merchant_group,id', + 'pay_type_id' => 'required|integer|min:1|exists:ma_payment_type,id', + 'pay_amount' => 'required|integer|min:1', + 'stat_date' => 'nullable|date_format:Y-m-d', + ]; + + protected array $attributes = [ + 'merchant_group_id' => '商户分组ID', + 'pay_type_id' => '支付方式', + 'pay_amount' => '支付金额', + 'stat_date' => '统计日期', + ]; + + protected array $scenes = [ + 'resolve' => ['merchant_group_id', 'pay_type_id', 'pay_amount', 'stat_date'], + ]; +} diff --git a/app/http/api/validation/SettlementActionValidator.php b/app/http/api/validation/SettlementActionValidator.php new file mode 100644 index 0000000..562a3be --- /dev/null +++ b/app/http/api/validation/SettlementActionValidator.php @@ -0,0 +1,30 @@ + 'required|string|min:1|max:64|exists:ma_settlement_order,settle_no', + 'reason' => 'nullable|string|max:255', + 'ext_json' => 'nullable|array', + ]; + + protected array $attributes = [ + 'settle_no' => '清算单号', + 'reason' => '原因', + 'ext_json' => '扩展信息', + ]; + + protected array $scenes = [ + 'complete' => ['settle_no'], + 'fail' => ['settle_no', 'reason', 'ext_json'], + ]; +} diff --git a/app/http/api/validation/SettlementCreateValidator.php b/app/http/api/validation/SettlementCreateValidator.php new file mode 100644 index 0000000..f5d130d --- /dev/null +++ b/app/http/api/validation/SettlementCreateValidator.php @@ -0,0 +1,63 @@ + 'sometimes|string|min:1|max:64', + 'merchant_id' => 'required|integer|min:1|exists:ma_merchant,id', + 'merchant_group_id' => 'required|integer|min:1|exists:ma_merchant_group,id', + 'channel_id' => 'required|integer|min:1|exists:ma_payment_channel,id', + 'cycle_type' => 'required|integer|min:0', + 'cycle_key' => 'required|string|min:1|max:64', + 'status' => 'sometimes|integer|min:0', + 'generated_at' => 'nullable|date_format:Y-m-d H:i:s', + 'accounted_amount' => 'nullable|integer|min:0', + 'gross_amount' => 'nullable|integer|min:0', + 'fee_amount' => 'nullable|integer|min:0', + 'refund_amount' => 'nullable|integer|min:0', + 'fee_reverse_amount' => 'nullable|integer|min:0', + 'net_amount' => 'nullable|integer|min:0', + 'ext_json' => 'nullable|array', + 'items' => 'nullable|array', + 'items.*.pay_no' => 'sometimes|string|min:1|max:64', + 'items.*.refund_no' => 'sometimes|string|min:1|max:64', + 'items.*.pay_amount' => 'sometimes|integer|min:0', + 'items.*.fee_amount' => 'sometimes|integer|min:0', + 'items.*.refund_amount' => 'sometimes|integer|min:0', + 'items.*.fee_reverse_amount' => 'sometimes|integer|min:0', + 'items.*.net_amount' => 'sometimes|integer|min:0', + 'items.*.item_status' => 'sometimes|integer|min:0', + ]; + + protected array $attributes = [ + 'settle_no' => '清算单号', + 'merchant_id' => '商户ID', + 'merchant_group_id' => '商户分组ID', + 'channel_id' => '通道ID', + 'cycle_type' => '结算周期类型', + 'cycle_key' => '结算周期键', + 'status' => '状态', + 'generated_at' => '生成时间', + 'accounted_amount' => '入账金额', + 'gross_amount' => '交易总额', + 'fee_amount' => '手续费', + 'refund_amount' => '退款金额', + 'fee_reverse_amount' => '手续费冲回', + 'net_amount' => '净额', + 'ext_json' => '扩展信息', + 'items' => '清算明细', + ]; + + protected array $scenes = [ + 'store' => ['settle_no', 'merchant_id', 'merchant_group_id', 'channel_id', 'cycle_type', 'cycle_key', 'status', 'generated_at', 'accounted_amount', 'gross_amount', 'fee_amount', 'refund_amount', 'fee_reverse_amount', 'net_amount', 'ext_json', 'items', 'items.*.pay_no', 'items.*.refund_no', 'items.*.pay_amount', 'items.*.fee_amount', 'items.*.refund_amount', 'items.*.fee_reverse_amount', 'items.*.net_amount', 'items.*.item_status'], + ]; +} diff --git a/app/http/api/validation/TraceQueryValidator.php b/app/http/api/validation/TraceQueryValidator.php new file mode 100644 index 0000000..8e99f83 --- /dev/null +++ b/app/http/api/validation/TraceQueryValidator.php @@ -0,0 +1,23 @@ + 'required|string|min:1|max:64', + ]; + + protected array $attributes = [ + 'trace_no' => '追踪号', + ]; + + protected array $scenes = [ + 'show' => ['trace_no'], + ]; +} diff --git a/app/http/mer/controller/account/AccountController.php b/app/http/mer/controller/account/AccountController.php new file mode 100644 index 0000000..1a47cfb --- /dev/null +++ b/app/http/mer/controller/account/AccountController.php @@ -0,0 +1,47 @@ +validated(['merchant_no' => $merchantNo], BalanceValidator::class, 'show'); + + $currentMerchantNo = $this->currentMerchantNo($request); + if ($currentMerchantNo !== '' && $currentMerchantNo !== (string) $data['merchant_no']) { + return $this->fail('无权查看该商户余额', 403); + } + + $merchant = $this->merchantService->findEnabledMerchantByNo((string) $data['merchant_no']); + + return $this->success($this->merchantAccountService->getBalanceSnapshot((int) $merchant->id)); + } +} + diff --git a/app/http/mer/controller/merchant/MerchantPortalController.php b/app/http/mer/controller/merchant/MerchantPortalController.php new file mode 100644 index 0000000..5da8914 --- /dev/null +++ b/app/http/mer/controller/merchant/MerchantPortalController.php @@ -0,0 +1,191 @@ +currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + return $this->success($this->merchantPortalService->profile($merchantId)); + } + + /** + * 更新当前商户资料。 + */ + public function updateProfile(Request $request): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $data = $this->validated($this->payload($request), MerchantPortalValidator::class, 'profileUpdate'); + + return $this->success($this->merchantPortalService->updateProfile($merchantId, $data)); + } + + /** + * 修改当前商户登录密码。 + */ + public function changePassword(Request $request): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $data = $this->validated($this->payload($request), MerchantPortalValidator::class, 'passwordUpdate'); + + return $this->success($this->merchantPortalService->changePassword($merchantId, $data)); + } + + /** + * 我的通道列表。 + */ + public function myChannels(Request $request): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $payload = $this->payload($request); + $page = max(1, (int) ($payload['page'] ?? 1)); + $pageSize = max(1, (int) ($payload['page_size'] ?? 10)); + + return $this->success($this->merchantPortalService->myChannels($payload, $merchantId, $page, $pageSize)); + } + + /** + * 路由预览。 + */ + public function routePreview(Request $request): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $payload = $this->validated($this->payload($request), MerchantPortalValidator::class, 'routePreview'); + $payTypeId = (int) ($payload['pay_type_id'] ?? 0); + $payAmount = (int) ($payload['pay_amount'] ?? 0); + $statDate = trim((string) ($payload['stat_date'] ?? '')); + + return $this->success($this->merchantPortalService->routePreview($merchantId, $payTypeId, $payAmount, $statDate)); + } + + /** + * 当前商户接口凭证。 + */ + public function apiCredential(Request $request): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + return $this->success($this->merchantPortalService->apiCredential($merchantId)); + } + + /** + * 生成或重置接口凭证。 + */ + public function issueCredential(Request $request): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + return $this->success($this->merchantPortalService->issueCredential($merchantId)); + } + + /** + * 当前商户的清算记录列表。 + */ + public function settlementRecords(Request $request): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $payload = $this->payload($request); + $page = max(1, (int) ($payload['page'] ?? 1)); + $pageSize = max(1, (int) ($payload['page_size'] ?? 10)); + + return $this->success($this->merchantPortalService->settlementRecords($payload, $merchantId, $page, $pageSize)); + } + + /** + * 当前商户的清算记录详情。 + */ + public function settlementRecordShow(Request $request, string $settleNo): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $detail = $this->merchantPortalService->settlementRecordDetail($settleNo, $merchantId); + if (!$detail) { + return $this->fail('清算记录不存在', 404); + } + + return $this->success($detail); + } + + /** + * 当前商户可提现余额快照。 + */ + public function withdrawableBalance(Request $request): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + return $this->success($this->merchantPortalService->withdrawableBalance($merchantId)); + } + + /** + * 当前商户资金流水列表。 + */ + public function balanceFlows(Request $request): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $payload = $this->payload($request); + $page = max(1, (int) ($payload['page'] ?? 1)); + $pageSize = max(1, (int) ($payload['page_size'] ?? 10)); + + return $this->success($this->merchantPortalService->balanceFlows($payload, $merchantId, $page, $pageSize)); + } +} diff --git a/app/http/mer/controller/system/AuthController.php b/app/http/mer/controller/system/AuthController.php new file mode 100644 index 0000000..fc1b922 --- /dev/null +++ b/app/http/mer/controller/system/AuthController.php @@ -0,0 +1,61 @@ +validated($request->all(), AuthValidator::class, 'login'); + + return $this->success($this->merchantAuthService->authenticateCredentials( + (string) $data['merchant_no'], + (string) $data['password'], + $request->getRealIp(), + $request->header('user-agent', '') + )); + } + + public function logout(Request $request): Response + { + $token = trim((string) ($request->header('authorization', '') ?: $request->header('x-merchant-token', ''))); + $token = preg_replace('/^Bearer\s+/i', '', $token) ?: $token; + + if ($token === '') { + return $this->fail('未获取到登录令牌', 401); + } + + $this->merchantAuthService->revokeToken($token); + + return $this->success(true); + } + + /** + * 获取当前登录商户的信息 + */ + public function profile(Request $request): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $merchantNo = $this->currentMerchantNo($request); + return $this->success($this->merchantAuthService->profile($merchantId, $merchantNo)); + } +} + diff --git a/app/http/mer/controller/system/SystemController.php b/app/http/mer/controller/system/SystemController.php new file mode 100644 index 0000000..6d1f399 --- /dev/null +++ b/app/http/mer/controller/system/SystemController.php @@ -0,0 +1,30 @@ +success($this->systemBootstrapService->getMenuTree('merchant')); + } + + public function dictItems(Request $request): Response + { + return $this->success($this->systemBootstrapService->getDictItems((string) $request->get('code', ''))); + } +} + diff --git a/app/http/mer/controller/trade/PayOrderController.php b/app/http/mer/controller/trade/PayOrderController.php new file mode 100644 index 0000000..7053e60 --- /dev/null +++ b/app/http/mer/controller/trade/PayOrderController.php @@ -0,0 +1,44 @@ +currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $data = $this->validated($request->all(), PayOrderValidator::class, 'index'); + $data['merchant_id'] = $merchantId; + $page = max(1, (int) ($data['page'] ?? 1)); + $pageSize = max(1, (int) ($data['page_size'] ?? 10)); + + return $this->success($this->payOrderService->paginate($data, $page, $pageSize, $merchantId)); + } +} + diff --git a/app/http/mer/controller/trade/RefundOrderController.php b/app/http/mer/controller/trade/RefundOrderController.php new file mode 100644 index 0000000..d82c236 --- /dev/null +++ b/app/http/mer/controller/trade/RefundOrderController.php @@ -0,0 +1,72 @@ +currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $data = $this->validated($request->all(), RefundOrderValidator::class, 'index'); + $data['merchant_id'] = $merchantId; + $page = max(1, (int) ($data['page'] ?? 1)); + $pageSize = max(1, (int) ($data['page_size'] ?? 10)); + + return $this->success($this->refundService->paginate($data, $page, $pageSize, $merchantId)); + } + + /** + * 查询当前商户的退款订单详情。 + */ + public function show(Request $request, string $refundNo): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + return $this->success($this->refundService->detail($refundNo, $merchantId)); + } + + /** + * 重试当前商户的退款单。 + */ + public function retry(Request $request, string $refundNo): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $data = $this->validated( + array_merge($request->all(), ['refund_no' => $refundNo]), + RefundActionValidator::class, + 'retry' + ); + + return $this->success($this->refundService->retryRefund($refundNo, $data, $merchantId)); + } +} + diff --git a/app/http/mer/middleware/MerchantAuthMiddleware.php b/app/http/mer/middleware/MerchantAuthMiddleware.php new file mode 100644 index 0000000..ff47285 --- /dev/null +++ b/app/http/mer/middleware/MerchantAuthMiddleware.php @@ -0,0 +1,63 @@ +header('authorization', '') ?: $request->header('x-merchant-token', ''))); + $token = preg_replace('/^Bearer\s+/i', '', $token) ?: $token; + + if ($token === '') { + if ((int) env('AUTH_MIDDLEWARE_STRICT', 1) === 1) { + return json([ + 'code' => 401, + 'msg' => 'merchant unauthorized', + 'data' => null, + ]); + } + } else { + $result = $this->merchantAuthService->authenticateToken( + $token, + $request->getRealIp(), + $request->header('user-agent', '') + ); + if (!$result) { + return json([ + 'code' => 401, + 'msg' => 'merchant unauthorized', + 'data' => null, + ]); + } + + Context::set('auth.merchant_id', (int) $result['merchant']->id); + Context::set('auth.merchant_no', (string) $result['merchant']->merchant_no); + } + + return $handler($request); + } +} + diff --git a/app/http/mer/validation/AuthValidator.php b/app/http/mer/validation/AuthValidator.php new file mode 100644 index 0000000..d234876 --- /dev/null +++ b/app/http/mer/validation/AuthValidator.php @@ -0,0 +1,25 @@ + 'required|string|min:1|max:32', + 'password' => 'required|string|min:6|max:32', + ]; + + protected array $attributes = [ + 'merchant_no' => '商户号', + 'password' => '登录密码', + ]; + + protected array $scenes = [ + 'login' => ['merchant_no', 'password'], + ]; +} diff --git a/app/http/mer/validation/BalanceValidator.php b/app/http/mer/validation/BalanceValidator.php new file mode 100644 index 0000000..bae0730 --- /dev/null +++ b/app/http/mer/validation/BalanceValidator.php @@ -0,0 +1,25 @@ + 'required|string|min:1|max:64', + ]; + + protected array $attributes = [ + 'merchant_no' => '商户号', + ]; + + protected array $scenes = [ + 'show' => ['merchant_no'], + ]; +} diff --git a/app/http/mer/validation/MerchantPortalValidator.php b/app/http/mer/validation/MerchantPortalValidator.php new file mode 100644 index 0000000..9a70d7c --- /dev/null +++ b/app/http/mer/validation/MerchantPortalValidator.php @@ -0,0 +1,60 @@ + 'sometimes|string|max:64', + 'contact_name' => 'sometimes|string|max:64', + 'contact_phone' => 'sometimes|string|max:32', + 'contact_email' => 'sometimes|email|max:128', + 'settlement_account_name' => 'sometimes|string|max:128', + 'settlement_account_no' => 'sometimes|string|max:128', + 'settlement_bank_name' => 'sometimes|string|max:128', + 'settlement_bank_branch' => 'sometimes|string|max:128', + 'current_password' => 'sometimes|string|min:6|max:32', + 'password' => 'sometimes|string|min:6|max:32', + 'password_confirm' => 'sometimes|string|min:6|max:32|same:password', + 'pay_type_id' => 'required|integer|min:1', + 'pay_amount' => 'required|integer|min:1', + 'stat_date' => 'sometimes|date', + ]; + + protected array $attributes = [ + 'merchant_short_name' => '商户简称', + 'contact_name' => '联系人', + 'contact_phone' => '联系电话', + 'contact_email' => '联系邮箱', + 'settlement_account_name' => '结算账户名', + 'settlement_account_no' => '结算账号', + 'settlement_bank_name' => '开户行', + 'settlement_bank_branch' => '开户支行', + 'current_password' => '当前密码', + 'password' => '新密码', + 'password_confirm' => '确认密码', + 'pay_type_id' => '支付方式', + 'pay_amount' => '支付金额', + 'stat_date' => '统计日期', + ]; + + protected array $scenes = [ + 'profileUpdate' => [ + 'merchant_short_name', + 'contact_name', + 'contact_phone', + 'contact_email', + 'settlement_account_name', + 'settlement_account_no', + 'settlement_bank_name', + 'settlement_bank_branch', + ], + 'passwordUpdate' => ['current_password', 'password', 'password_confirm'], + 'routePreview' => ['pay_type_id', 'pay_amount', 'stat_date'], + ]; +} diff --git a/app/http/mer/validation/PayOrderValidator.php b/app/http/mer/validation/PayOrderValidator.php new file mode 100644 index 0000000..f6955e3 --- /dev/null +++ b/app/http/mer/validation/PayOrderValidator.php @@ -0,0 +1,39 @@ + 'sometimes|string|max:128', + 'merchant_id' => 'sometimes|integer|min:1', + 'pay_type_id' => 'sometimes|integer|min:1', + 'status' => 'sometimes|integer|in:0,1,2,3,4,5', + 'channel_mode' => 'sometimes|integer|in:0,1', + 'callback_status' => 'sometimes|integer|in:0,1,2', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'keyword' => '关键字', + 'merchant_id' => '商户ID', + 'pay_type_id' => '支付方式', + 'status' => '状态', + 'channel_mode' => '通道模式', + 'callback_status' => '回调状态', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'merchant_id', 'pay_type_id', 'status', 'channel_mode', 'callback_status', 'page', 'page_size'], + ]; +} diff --git a/app/http/mer/validation/RefundActionValidator.php b/app/http/mer/validation/RefundActionValidator.php new file mode 100644 index 0000000..75157f3 --- /dev/null +++ b/app/http/mer/validation/RefundActionValidator.php @@ -0,0 +1,36 @@ + 'required|string|max:64', + 'processing_at' => 'sometimes|date_format:Y-m-d H:i:s', + 'failed_at' => 'sometimes|date_format:Y-m-d H:i:s', + 'last_error' => 'sometimes|string|max:512', + 'channel_refund_no' => 'sometimes|string|max:64', + ]; + + protected array $attributes = [ + 'refund_no' => '退款单号', + 'processing_at' => '处理时间', + 'failed_at' => '失败时间', + 'last_error' => '错误信息', + 'channel_refund_no' => '渠道退款单号', + ]; + + protected array $scenes = [ + 'retry' => ['refund_no', 'processing_at'], + 'mark_fail' => ['refund_no', 'failed_at', 'last_error'], + 'mark_processing' => ['refund_no', 'processing_at'], + 'mark_success' => ['refund_no', 'channel_refund_no'], + ]; +} diff --git a/app/http/mer/validation/RefundOrderValidator.php b/app/http/mer/validation/RefundOrderValidator.php new file mode 100644 index 0000000..b69ca9e --- /dev/null +++ b/app/http/mer/validation/RefundOrderValidator.php @@ -0,0 +1,37 @@ + 'sometimes|string|max:128', + 'merchant_id' => 'sometimes|integer|min:1', + 'pay_type_id' => 'sometimes|integer|min:1', + 'status' => 'sometimes|integer|in:0,1,2,3,4', + 'channel_mode' => 'sometimes|integer|in:0,1', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + protected array $attributes = [ + 'keyword' => '关键字', + 'merchant_id' => '商户ID', + 'pay_type_id' => '支付方式', + 'status' => '退款状态', + 'channel_mode' => '通道模式', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + protected array $scenes = [ + 'index' => ['keyword', 'merchant_id', 'pay_type_id', 'status', 'channel_mode', 'page', 'page_size'], + ]; +} diff --git a/app/jobs/NotifyMerchantJob.php b/app/jobs/NotifyMerchantJob.php deleted file mode 100644 index 0f70010..0000000 --- a/app/jobs/NotifyMerchantJob.php +++ /dev/null @@ -1,51 +0,0 @@ -notifyTaskRepository->getPendingRetryTasks(100); - - foreach ($tasks as $taskData) { - try { - $task = $this->notifyTaskRepository->find($taskData['id']); - if (!$task) { - continue; - } - - if ($task->retry_cnt >= 10) { - $this->notifyTaskRepository->updateById($task->id, [ - 'status' => PaymentNotifyTask::STATUS_FAIL, - ]); - continue; - } - - $this->notifyService->sendNotify($task); - } catch (\Throwable $e) { - Log::error('通知任务处理失败', [ - 'task_id' => $taskData['id'] ?? 0, - 'error' => $e->getMessage(), - ]); - } - } - } -} - diff --git a/app/listener/SystemConfigChangedListener.php b/app/listener/SystemConfigChangedListener.php new file mode 100644 index 0000000..33e00a1 --- /dev/null +++ b/app/listener/SystemConfigChangedListener.php @@ -0,0 +1,18 @@ +systemConfigRuntimeService->refresh(); + } +} diff --git a/app/model/admin/AdminUser.php b/app/model/admin/AdminUser.php new file mode 100644 index 0000000..9d0facb --- /dev/null +++ b/app/model/admin/AdminUser.php @@ -0,0 +1,41 @@ + 'integer', + 'status' => 'integer', + 'last_login_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + + diff --git a/app/model/admin/ChannelDailyStat.php b/app/model/admin/ChannelDailyStat.php new file mode 100644 index 0000000..e0f3e7b --- /dev/null +++ b/app/model/admin/ChannelDailyStat.php @@ -0,0 +1,48 @@ + 'integer', + 'merchant_group_id' => 'integer', + 'channel_id' => 'integer', + 'pay_success_count' => 'integer', + 'pay_fail_count' => 'integer', + 'pay_amount' => 'integer', + 'refund_count' => 'integer', + 'refund_amount' => 'integer', + 'avg_latency_ms' => 'integer', + 'success_rate_bp' => 'integer', + 'health_score' => 'integer', + 'stat_date' => 'date', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + + diff --git a/app/model/admin/ChannelNotifyLog.php b/app/model/admin/ChannelNotifyLog.php new file mode 100644 index 0000000..9100f7a --- /dev/null +++ b/app/model/admin/ChannelNotifyLog.php @@ -0,0 +1,47 @@ + 'integer', + 'notify_type' => 'integer', + 'verify_status' => 'integer', + 'process_status' => 'integer', + 'retry_count' => 'integer', + 'next_retry_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + + diff --git a/app/models/PaymentCallbackLog.php b/app/model/admin/PayCallbackLog.php similarity index 53% rename from app/models/PaymentCallbackLog.php rename to app/model/admin/PayCallbackLog.php index 845cc2e..d7e332b 100644 --- a/app/models/PaymentCallbackLog.php +++ b/app/model/admin/PayCallbackLog.php @@ -1,20 +1,21 @@ 'integer', + 'callback_type' => 'integer', 'verify_status' => 'integer', 'process_status' => 'integer', + 'created_at' => 'datetime', ]; } + + diff --git a/app/model/file/FileRecord.php b/app/model/file/FileRecord.php new file mode 100644 index 0000000..ade3f1f --- /dev/null +++ b/app/model/file/FileRecord.php @@ -0,0 +1,43 @@ + 'integer', + 'source_type' => 'integer', + 'visibility' => 'integer', + 'storage_engine' => 'integer', + 'size' => 'integer', + 'created_by' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} diff --git a/app/model/merchant/Merchant.php b/app/model/merchant/Merchant.php new file mode 100644 index 0000000..aaf1262 --- /dev/null +++ b/app/model/merchant/Merchant.php @@ -0,0 +1,51 @@ + 'integer', + 'group_id' => 'integer', + 'risk_level' => 'integer', + 'status' => 'integer', + 'last_login_at' => 'datetime', + 'password_updated_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} diff --git a/app/model/merchant/MerchantAccount.php b/app/model/merchant/MerchantAccount.php new file mode 100644 index 0000000..63bfcc3 --- /dev/null +++ b/app/model/merchant/MerchantAccount.php @@ -0,0 +1,28 @@ + 'integer', + 'available_balance' => 'integer', + 'frozen_balance' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} diff --git a/app/model/merchant/MerchantAccountLedger.php b/app/model/merchant/MerchantAccountLedger.php new file mode 100644 index 0000000..640724c --- /dev/null +++ b/app/model/merchant/MerchantAccountLedger.php @@ -0,0 +1,50 @@ + 'integer', + 'biz_type' => 'integer', + 'event_type' => 'integer', + 'direction' => 'integer', + 'amount' => 'integer', + 'available_before' => 'integer', + 'available_after' => 'integer', + 'frozen_before' => 'integer', + 'frozen_after' => 'integer', + 'ext_json' => 'array', + 'created_at' => 'datetime', + ]; +} + + diff --git a/app/model/merchant/MerchantApiCredential.php b/app/model/merchant/MerchantApiCredential.php new file mode 100644 index 0000000..215d9be --- /dev/null +++ b/app/model/merchant/MerchantApiCredential.php @@ -0,0 +1,35 @@ + 'integer', + 'sign_type' => 'integer', + 'status' => 'integer', + 'last_used_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} diff --git a/app/model/merchant/MerchantGroup.php b/app/model/merchant/MerchantGroup.php new file mode 100644 index 0000000..4b6ff9d --- /dev/null +++ b/app/model/merchant/MerchantGroup.php @@ -0,0 +1,27 @@ + 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + diff --git a/app/model/merchant/MerchantPolicy.php b/app/model/merchant/MerchantPolicy.php new file mode 100644 index 0000000..f72a6ab --- /dev/null +++ b/app/model/merchant/MerchantPolicy.php @@ -0,0 +1,41 @@ + 'integer', + 'settlement_cycle_override' => 'integer', + 'auto_payout' => 'integer', + 'min_settlement_amount' => 'integer', + 'retry_policy_json' => 'array', + 'route_policy_json' => 'array', + 'fee_rule_override_json' => 'array', + 'risk_policy_json' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + + diff --git a/app/model/payment/BizOrder.php b/app/model/payment/BizOrder.php new file mode 100644 index 0000000..6725811 --- /dev/null +++ b/app/model/payment/BizOrder.php @@ -0,0 +1,58 @@ + 'integer', + 'merchant_group_id' => 'integer', + 'poll_group_id' => 'integer', + 'order_amount' => 'integer', + 'paid_amount' => 'integer', + 'refund_amount' => 'integer', + 'status' => 'integer', + 'attempt_count' => 'integer', + 'expire_at' => 'datetime', + 'paid_at' => 'datetime', + 'closed_at' => 'datetime', + 'failed_at' => 'datetime', + 'timeout_at' => 'datetime', + 'ext_json' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + + diff --git a/app/model/payment/NotifyTask.php b/app/model/payment/NotifyTask.php new file mode 100644 index 0000000..101a9d5 --- /dev/null +++ b/app/model/payment/NotifyTask.php @@ -0,0 +1,47 @@ + 'integer', + 'merchant_group_id' => 'integer', + 'status' => 'integer', + 'retry_count' => 'integer', + 'next_retry_at' => 'datetime', + 'last_notify_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + + diff --git a/app/model/payment/PayOrder.php b/app/model/payment/PayOrder.php new file mode 100644 index 0000000..4580279 --- /dev/null +++ b/app/model/payment/PayOrder.php @@ -0,0 +1,83 @@ + 'integer', + 'merchant_group_id' => 'integer', + 'poll_group_id' => 'integer', + 'attempt_no' => 'integer', + 'channel_id' => 'integer', + 'pay_type_id' => 'integer', + 'channel_type' => 'integer', + 'channel_mode' => 'integer', + 'pay_amount' => 'integer', + 'fee_rate_bp_snapshot' => 'integer', + 'split_rate_bp_snapshot' => 'integer', + 'fee_estimated_amount' => 'integer', + 'fee_actual_amount' => 'integer', + 'status' => 'integer', + 'fee_status' => 'integer', + 'settlement_status' => 'integer', + 'request_at' => 'datetime', + 'paid_at' => 'datetime', + 'expire_at' => 'datetime', + 'closed_at' => 'datetime', + 'failed_at' => 'datetime', + 'timeout_at' => 'datetime', + 'callback_status' => 'integer', + 'callback_times' => 'integer', + 'ext_json' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + + diff --git a/app/model/payment/PaymentChannel.php b/app/model/payment/PaymentChannel.php new file mode 100644 index 0000000..f712a1a --- /dev/null +++ b/app/model/payment/PaymentChannel.php @@ -0,0 +1,51 @@ + 'integer', + 'split_rate_bp' => 'integer', + 'cost_rate_bp' => 'integer', + 'channel_mode' => 'integer', + 'pay_type_id' => 'integer', + 'api_config_id' => 'integer', + 'daily_limit_amount' => 'integer', + 'daily_limit_count' => 'integer', + 'min_amount' => 'integer', + 'max_amount' => 'integer', + 'status' => 'integer', + 'sort_no' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + + diff --git a/app/models/PaymentPlugin.php b/app/model/payment/PaymentPlugin.php similarity index 66% rename from app/models/PaymentPlugin.php rename to app/model/payment/PaymentPlugin.php index 36ed767..da0cf79 100644 --- a/app/models/PaymentPlugin.php +++ b/app/model/payment/PaymentPlugin.php @@ -1,17 +1,16 @@ 'integer', 'config_schema' => 'array', 'pay_types' => 'array', 'transfer_types' => 'array', + 'status' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', ]; } + + diff --git a/app/model/payment/PaymentPluginConf.php b/app/model/payment/PaymentPluginConf.php new file mode 100644 index 0000000..1d26da8 --- /dev/null +++ b/app/model/payment/PaymentPluginConf.php @@ -0,0 +1,31 @@ + 'array', + 'settlement_cycle_type' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + + diff --git a/app/model/payment/PaymentPollGroup.php b/app/model/payment/PaymentPollGroup.php new file mode 100644 index 0000000..2fcf4aa --- /dev/null +++ b/app/model/payment/PaymentPollGroup.php @@ -0,0 +1,31 @@ + 'integer', + 'route_mode' => 'integer', + 'status' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + diff --git a/app/model/payment/PaymentPollGroupBind.php b/app/model/payment/PaymentPollGroupBind.php new file mode 100644 index 0000000..d355186 --- /dev/null +++ b/app/model/payment/PaymentPollGroupBind.php @@ -0,0 +1,33 @@ + 'integer', + 'pay_type_id' => 'integer', + 'poll_group_id' => 'integer', + 'status' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + + diff --git a/app/model/payment/PaymentPollGroupChannel.php b/app/model/payment/PaymentPollGroupChannel.php new file mode 100644 index 0000000..6e5a919 --- /dev/null +++ b/app/model/payment/PaymentPollGroupChannel.php @@ -0,0 +1,37 @@ + 'integer', + 'channel_id' => 'integer', + 'sort_no' => 'integer', + 'weight' => 'integer', + 'is_default' => 'integer', + 'status' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + + diff --git a/app/model/payment/PaymentType.php b/app/model/payment/PaymentType.php new file mode 100644 index 0000000..fb3eee0 --- /dev/null +++ b/app/model/payment/PaymentType.php @@ -0,0 +1,32 @@ + 'integer', + 'status' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + + diff --git a/app/model/payment/RefundOrder.php b/app/model/payment/RefundOrder.php new file mode 100644 index 0000000..c20598d --- /dev/null +++ b/app/model/payment/RefundOrder.php @@ -0,0 +1,57 @@ + 'integer', + 'merchant_group_id' => 'integer', + 'channel_id' => 'integer', + 'refund_amount' => 'integer', + 'fee_reverse_amount' => 'integer', + 'status' => 'integer', + 'request_at' => 'datetime', + 'processing_at' => 'datetime', + 'succeeded_at' => 'datetime', + 'failed_at' => 'datetime', + 'retry_count' => 'integer', + 'ext_json' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + + diff --git a/app/model/payment/SettlementItem.php b/app/model/payment/SettlementItem.php new file mode 100644 index 0000000..61465b9 --- /dev/null +++ b/app/model/payment/SettlementItem.php @@ -0,0 +1,45 @@ + 'integer', + 'merchant_group_id' => 'integer', + 'channel_id' => 'integer', + 'pay_amount' => 'integer', + 'fee_amount' => 'integer', + 'refund_amount' => 'integer', + 'fee_reverse_amount' => 'integer', + 'net_amount' => 'integer', + 'item_status' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + + diff --git a/app/model/payment/SettlementOrder.php b/app/model/payment/SettlementOrder.php new file mode 100644 index 0000000..ed28c58 --- /dev/null +++ b/app/model/payment/SettlementOrder.php @@ -0,0 +1,60 @@ + 'integer', + 'merchant_group_id' => 'integer', + 'channel_id' => 'integer', + 'cycle_type' => 'integer', + 'status' => 'integer', + 'gross_amount' => 'integer', + 'fee_amount' => 'integer', + 'refund_amount' => 'integer', + 'fee_reverse_amount' => 'integer', + 'net_amount' => 'integer', + 'accounted_amount' => 'integer', + 'generated_at' => 'datetime', + 'accounted_at' => 'datetime', + 'completed_at' => 'datetime', + 'failed_at' => 'datetime', + 'ext_json' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + + diff --git a/app/model/system/SystemConfig.php b/app/model/system/SystemConfig.php new file mode 100644 index 0000000..c36c412 --- /dev/null +++ b/app/model/system/SystemConfig.php @@ -0,0 +1,32 @@ + 'datetime', + 'updated_at' => 'datetime', + ]; +} + diff --git a/app/models/Admin.php b/app/models/Admin.php deleted file mode 100644 index 513c7bd..0000000 --- a/app/models/Admin.php +++ /dev/null @@ -1,36 +0,0 @@ - 'integer', - 'login_at' => 'datetime', - ]; - - protected $hidden = ['password']; -} diff --git a/app/models/CallbackInbox.php b/app/models/CallbackInbox.php deleted file mode 100644 index c3fba1f..0000000 --- a/app/models/CallbackInbox.php +++ /dev/null @@ -1,34 +0,0 @@ - 'array', - 'process_status' => 'integer', - 'processed_at' => 'datetime', - ]; -} - diff --git a/app/models/Merchant.php b/app/models/Merchant.php deleted file mode 100644 index 81da6eb..0000000 --- a/app/models/Merchant.php +++ /dev/null @@ -1,33 +0,0 @@ - 'decimal:2', - 'status' => 'integer', - 'extra' => 'array', - ]; -} - diff --git a/app/models/MerchantApp.php b/app/models/MerchantApp.php deleted file mode 100644 index b758ed6..0000000 --- a/app/models/MerchantApp.php +++ /dev/null @@ -1,73 +0,0 @@ - 'integer', - 'order_expire_minutes' => 'integer', - 'callback_retry_limit' => 'integer', - 'amount_min' => 'decimal:2', - 'amount_max' => 'decimal:2', - 'daily_limit' => 'decimal:2', - 'notify_enabled' => 'integer', - 'status' => 'integer', - 'extra' => 'array', - ]; - - public function getMerchantIdAttribute() - { - return $this->attributes['mer_id'] ?? null; - } - - public function setMerchantIdAttribute($value): void - { - $this->attributes['mer_id'] = (int)$value; - } - - public function getAppIdAttribute() - { - return $this->attributes['app_code'] ?? null; - } - - public function setAppIdAttribute($value): void - { - $this->attributes['app_code'] = (string)$value; - } -} - diff --git a/app/models/MerchantUser.php b/app/models/MerchantUser.php deleted file mode 100644 index 36f1e0a..0000000 --- a/app/models/MerchantUser.php +++ /dev/null @@ -1,51 +0,0 @@ - 'integer', - 'is_owner' => 'integer', - 'status' => 'integer', - 'login_at' => 'datetime', - ]; - - protected $hidden = ['password']; - - public function getMerchantIdAttribute() - { - return $this->attributes['mer_id'] ?? null; - } - - public function setMerchantIdAttribute($value): void - { - $this->attributes['mer_id'] = (int)$value; - } -} diff --git a/app/models/PaymentChannel.php b/app/models/PaymentChannel.php deleted file mode 100644 index 37dedd7..0000000 --- a/app/models/PaymentChannel.php +++ /dev/null @@ -1,105 +0,0 @@ - 'integer', - 'app_id' => 'integer', - 'pay_type_id' => 'integer', - 'config' => 'array', - 'split_ratio' => 'decimal:2', - 'chan_cost' => 'decimal:2', - 'chan_mode' => 'integer', - 'daily_limit' => 'decimal:2', - 'daily_cnt' => 'integer', - 'min_amount' => 'decimal:2', - 'max_amount' => 'decimal:2', - 'status' => 'integer', - 'sort' => 'integer', - ]; - - public function getConfigArray(): array - { - return $this->config ?? []; - } - - public function getEnabledProducts(): array - { - $config = $this->getConfigArray(); - return $config['enabled_products'] ?? []; - } - - public function getMerchantIdAttribute() - { - return $this->attributes['mer_id'] ?? null; - } - - public function setMerchantIdAttribute($value): void - { - $this->attributes['mer_id'] = (int)$value; - } - - public function getMerchantAppIdAttribute() - { - return $this->attributes['app_id'] ?? null; - } - - public function setMerchantAppIdAttribute($value): void - { - $this->attributes['app_id'] = (int)$value; - } - - public function getMethodIdAttribute() - { - return $this->attributes['pay_type_id'] ?? null; - } - - public function setMethodIdAttribute($value): void - { - $this->attributes['pay_type_id'] = (int)$value; - } - - public function getConfigJsonAttribute() - { - return $this->attributes['config'] ?? []; - } - - public function setConfigJsonAttribute($value): void - { - $this->attributes['config'] = $value; - } -} diff --git a/app/models/PaymentMethod.php b/app/models/PaymentMethod.php deleted file mode 100644 index 1691ad5..0000000 --- a/app/models/PaymentMethod.php +++ /dev/null @@ -1,52 +0,0 @@ - 'integer', - 'status' => 'integer', - ]; - - public function getMethodCodeAttribute() - { - return $this->attributes['type'] ?? null; - } - - public function setMethodCodeAttribute($value): void - { - $this->attributes['type'] = (string)$value; - } - - public function getMethodNameAttribute() - { - return $this->attributes['name'] ?? null; - } - - public function setMethodNameAttribute($value): void - { - $this->attributes['name'] = (string)$value; - } -} diff --git a/app/models/PaymentNotifyTask.php b/app/models/PaymentNotifyTask.php deleted file mode 100644 index 8684639..0000000 --- a/app/models/PaymentNotifyTask.php +++ /dev/null @@ -1,42 +0,0 @@ - 'integer', - 'merchant_app_id' => 'integer', - 'retry_cnt' => 'integer', - 'next_retry_at' => 'datetime', - 'last_notify_at' => 'datetime', - ]; - - const STATUS_PENDING = 'PENDING'; - const STATUS_SUCCESS = 'SUCCESS'; - const STATUS_FAIL = 'FAIL'; -} diff --git a/app/models/PaymentOrder.php b/app/models/PaymentOrder.php deleted file mode 100644 index eff150b..0000000 --- a/app/models/PaymentOrder.php +++ /dev/null @@ -1,63 +0,0 @@ - 'integer', - 'merchant_app_id' => 'integer', - 'method_id' => 'integer', - 'channel_id' => 'integer', - 'amount' => 'decimal:2', - 'real_amount' => 'decimal:2', - 'fee' => 'decimal:2', - 'status' => 'integer', - 'notify_stat' => 'integer', - 'notify_cnt' => 'integer', - 'extra' => 'array', - 'pay_at' => 'datetime', - 'expire_at' => 'datetime', - ]; - - /* 订单状态 */ - const STATUS_PENDING = 0; // 待支付 - const STATUS_SUCCESS = 1; // 支付成功 - const STATUS_FAIL = 2; // 支付失败 - const STATUS_CLOSED = 3; // 已关闭 -} diff --git a/app/models/SystemConfig.php b/app/models/SystemConfig.php deleted file mode 100644 index e0754d0..0000000 --- a/app/models/SystemConfig.php +++ /dev/null @@ -1,38 +0,0 @@ - - * @copyright walkor - * @link http://www.workerman.net/ - * @license http://www.opensource.org/licenses/mit-license.php MIT License + * 许可证信息与版权声明保持不变。 */ namespace app\process; @@ -22,34 +15,32 @@ use Workerman\Timer; use Workerman\Worker; /** - * Class FileMonitor - * @package process + * 文件监控器。 */ class Monitor { /** - * @var array + * 监控路径列表。 */ protected array $paths = []; /** - * @var array + * 监控扩展名列表。 */ protected array $extensions = []; /** - * @var array + * 已加载文件列表。 */ protected array $loadedFiles = []; /** - * @var int + * 父进程 ID。 */ protected int $ppid = 0; /** - * Pause monitor - * @return void + * 暂停监控。 */ public static function pause(): void { @@ -57,8 +48,7 @@ class Monitor } /** - * Resume monitor - * @return void + * 恢复监控。 */ public static function resume(): void { @@ -69,8 +59,7 @@ class Monitor } /** - * Whether monitor is paused - * @return bool + * 判断监控是否已暂停。 */ public static function isPaused(): bool { @@ -79,8 +68,7 @@ class Monitor } /** - * Lock file - * @return string + * 锁文件路径。 */ protected static function lockFile(): string { @@ -88,10 +76,7 @@ class Monitor } /** - * FileMonitor constructor. - * @param $monitorDir - * @param $monitorExtensions - * @param array $options + * 构造文件监控器。 */ public function __construct($monitorDir, $monitorExtensions, array $options = []) { @@ -110,7 +95,7 @@ class Monitor } $disableFunctions = explode(',', ini_get('disable_functions')); if (in_array('exec', $disableFunctions, true)) { - echo "\nMonitor file change turned off because exec() has been disabled by disable_functions setting in " . PHP_CONFIG_FILE_PATH . "/php.ini\n"; + echo "\n由于 php.ini 的 disable_functions 禁用了 exec(),文件监控已关闭:" . PHP_CONFIG_FILE_PATH . "/php.ini\n"; } else { if ($options['enable_file_monitor'] ?? true) { Timer::add(1, function () { @@ -126,8 +111,7 @@ class Monitor } /** - * @param $monitorDir - * @return bool + * 检查指定路径是否有文件变化。 */ public function checkFilesChange($monitorDir): bool { @@ -142,22 +126,22 @@ class Monitor } $iterator = [new SplFileInfo($monitorDir)]; } else { - // recursive traversal directory + // 递归遍历目录 $dirIterator = new RecursiveDirectoryIterator($monitorDir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS); $iterator = new RecursiveIteratorIterator($dirIterator); } $count = 0; foreach ($iterator as $file) { $count ++; - /** var SplFileInfo $file */ + /** @var SplFileInfo $file */ if (is_dir($file->getRealPath())) { continue; } - // check mtime + // 检查修改时间 if (in_array($file->getExtension(), $this->extensions, true) && $lastMtime < $file->getMTime()) { $lastMtime = $file->getMTime(); if (DIRECTORY_SEPARATOR === '/' && isset($this->loadedFiles[$file->getRealPath()])) { - echo "$file updated but cannot be reloaded because only auto-loaded files support reload.\n"; + echo "$file 已更新,但无法重载,因为当前仅支持自动加载文件重载。\n"; continue; } $var = 0; @@ -165,22 +149,22 @@ class Monitor if ($var) { continue; } - // send SIGUSR1 signal to master process for reload + // 向主进程发送 SIGUSR1 信号触发重载 if (DIRECTORY_SEPARATOR === '/') { if ($masterPid = $this->getMasterPid()) { - echo $file . " updated and reload\n"; + echo $file . " 已更新并触发重载\n"; posix_kill($masterPid, SIGUSR1); } else { - echo "Master process has gone away and can not reload\n"; + echo "主进程已退出,无法重载\n"; } return true; } - echo $file . " updated and reload\n"; + echo $file . " 已更新并触发重载\n"; return true; } } if (!$tooManyFilesCheck && $count > 1000) { - echo "Monitor: There are too many files ($count files) in $monitorDir which makes file monitoring very slow\n"; + echo "监控目录 $monitorDir 下文件过多($count 个),文件监控会变慢\n"; $tooManyFilesCheck = 1; } return false; @@ -195,7 +179,7 @@ class Monitor return 0; } if (function_exists('posix_kill') && !posix_kill($this->ppid, 0)) { - echo "Master process has gone away\n"; + echo "主进程已退出\n"; return $this->ppid = 0; } if (PHP_OS_FAMILY !== 'Linux') { @@ -203,14 +187,14 @@ class Monitor } $cmdline = "/proc/$this->ppid/cmdline"; if (!is_readable($cmdline) || !($content = file_get_contents($cmdline)) || (!str_contains($content, 'WorkerMan') && !str_contains($content, 'php'))) { - // Process not exist + // 进程不存在 $this->ppid = 0; } return $this->ppid; } /** - * @return bool + * 检查所有监控路径是否有变化。 */ public function checkAllFilesChange(): bool { @@ -226,8 +210,7 @@ class Monitor } /** - * @param $memoryLimit - * @return void + * 检查子进程内存占用。 */ public function checkMemory($memoryLimit): void { @@ -262,9 +245,7 @@ class Monitor } /** - * Get memory limit - * @param $memoryLimit - * @return int + * 计算内存限制值。 */ protected function getMemoryLimit($memoryLimit): int { diff --git a/app/repositories/AdminRepository.php b/app/repositories/AdminRepository.php deleted file mode 100644 index e321caa..0000000 --- a/app/repositories/AdminRepository.php +++ /dev/null @@ -1,31 +0,0 @@ -model - ->newQuery() - ->where('user_name', $userName) - ->first(); - - return $admin; - } -} diff --git a/app/repositories/CallbackInboxRepository.php b/app/repositories/CallbackInboxRepository.php deleted file mode 100644 index 209b63e..0000000 --- a/app/repositories/CallbackInboxRepository.php +++ /dev/null @@ -1,43 +0,0 @@ -model->newQuery() - ->where('event_key', $eventKey) - ->first(); - } - - /** - * 尝试创建幂等事件,重复时返回 false。 - */ - public function createIfAbsent(array $data): bool - { - try { - $this->model->newQuery()->create($data); - return true; - } catch (QueryException $e) { - // 1062: duplicate entry - if ((int)($e->errorInfo[1] ?? 0) === 1062) { - return false; - } - throw $e; - } - } -} - diff --git a/app/repositories/MerchantAppRepository.php b/app/repositories/MerchantAppRepository.php deleted file mode 100644 index 14f3974..0000000 --- a/app/repositories/MerchantAppRepository.php +++ /dev/null @@ -1,99 +0,0 @@ -model->newQuery() - ->where('app_code', $appId) - ->where('status', 1) - ->first(); - } - - /** - * 根据商户ID和应用ID查询 - */ - public function findByMerchantAndApp(int $merchantId, int $appId): ?MerchantApp - { - return $this->model->newQuery() - ->where('mer_id', $merchantId) - ->where('id', $appId) - ->where('status', 1) - ->first(); - } - - /** - * 根据商户ID和应用ID(app_id)查询 - */ - public function findByMerchantAndAppId(int $merchantId, string $appId): ?MerchantApp - { - return $this->model->newQuery() - ->where('mer_id', $merchantId) - ->where('app_code', $appId) - ->first(); - } - - /** - * 后台按 app_id 查询(不过滤状态) - */ - public function findAnyByAppId(string $appId): ?MerchantApp - { - return $this->model->newQuery() - ->where('app_code', $appId) - ->first(); - } - - /** - * 后台列表:支持筛选与模糊搜索 - */ - public function searchPaginate(array $filters = [], int $page = 1, int $pageSize = 10) - { - $query = $this->model->newQuery(); - - if (!empty($filters['merchant_id'])) { - $query->where('mer_id', (int)$filters['merchant_id']); - } - if (($filters['status'] ?? '') !== '' && $filters['status'] !== null) { - $query->where('status', (int)$filters['status']); - } - if (!empty($filters['app_id'])) { - $query->where('app_code', 'like', '%' . $filters['app_id'] . '%'); - } - if (!empty($filters['app_name'])) { - $query->where('app_name', 'like', '%' . $filters['app_name'] . '%'); - } - if (!empty($filters['api_type'])) { - $query->where('api_type', (string)$filters['api_type']); - } - if (!empty($filters['package_code'])) { - $query->where('package_code', (string)$filters['package_code']); - } - if (($filters['notify_enabled'] ?? '') !== '' && $filters['notify_enabled'] !== null) { - $query->where('notify_enabled', (int)$filters['notify_enabled']); - } - if (!empty($filters['callback_mode'])) { - $query->where('callback_mode', (string)$filters['callback_mode']); - } - - $query->orderByDesc('id'); - - return $query->paginate($pageSize, ['*'], 'page', $page); - } -} - diff --git a/app/repositories/MerchantRepository.php b/app/repositories/MerchantRepository.php deleted file mode 100644 index 776bd73..0000000 --- a/app/repositories/MerchantRepository.php +++ /dev/null @@ -1,56 +0,0 @@ -model->newQuery() - ->where('merchant_no', $merchantNo) - ->first(); - } - - /** - * 后台列表:支持模糊搜索 - */ - public function searchPaginate(array $filters = [], int $page = 1, int $pageSize = 10) - { - $query = $this->model->newQuery(); - - if (($filters['status'] ?? '') !== '' && $filters['status'] !== null) { - $query->where('status', (int)$filters['status']); - } - if (!empty($filters['merchant_no'])) { - $query->where('merchant_no', 'like', '%' . $filters['merchant_no'] . '%'); - } - if (!empty($filters['merchant_name'])) { - $query->where('merchant_name', 'like', '%' . $filters['merchant_name'] . '%'); - } - if (!empty($filters['email'])) { - $query->where('email', 'like', '%' . $filters['email'] . '%'); - } - if (isset($filters['balance']) && $filters['balance'] !== '') { - $query->where('balance', (string)$filters['balance']); - } - - $query->orderByDesc('id'); - - return $query->paginate($pageSize, ['*'], 'page', $page); - } -} - diff --git a/app/repositories/PaymentCallbackLogRepository.php b/app/repositories/PaymentCallbackLogRepository.php deleted file mode 100644 index c08f2bf..0000000 --- a/app/repositories/PaymentCallbackLogRepository.php +++ /dev/null @@ -1,22 +0,0 @@ -model->newQuery()->create($data); - } -} diff --git a/app/repositories/PaymentChannelRepository.php b/app/repositories/PaymentChannelRepository.php deleted file mode 100644 index 79e5e68..0000000 --- a/app/repositories/PaymentChannelRepository.php +++ /dev/null @@ -1,77 +0,0 @@ -model->newQuery() - ->where('mer_id', $merchantId) - ->where('app_id', $merchantAppId) - ->where('pay_type_id', $methodId) - ->where('status', 1) - ->orderBy('sort', 'asc') - ->first(); - } - - public function findByChanCode(string $chanCode): ?PaymentChannel - { - return $this->model->newQuery() - ->where('chan_code', $chanCode) - ->first(); - } - - public function searchPaginate(array $filters = [], int $page = 1, int $pageSize = 10) - { - $query = $this->buildSearchQuery($filters); - $query->orderBy('sort', 'asc')->orderByDesc('id'); - - return $query->paginate($pageSize, ['*'], 'page', $page); - } - - public function searchList(array $filters = []) - { - return $this->buildSearchQuery($filters) - ->orderBy('sort', 'asc') - ->orderByDesc('id') - ->get(); - } - - private function buildSearchQuery(array $filters = []) - { - $query = $this->model->newQuery(); - - if (!empty($filters['merchant_id'])) { - $query->where('mer_id', (int)$filters['merchant_id']); - } - if (!empty($filters['merchant_app_id'])) { - $query->where('app_id', (int)$filters['merchant_app_id']); - } - if (!empty($filters['method_id'])) { - $query->where('pay_type_id', (int)$filters['method_id']); - } - if (($filters['status'] ?? '') !== '' && $filters['status'] !== null) { - $query->where('status', (int)$filters['status']); - } - if (!empty($filters['plugin_code'])) { - $query->where('plugin_code', (string)$filters['plugin_code']); - } - if (!empty($filters['chan_code'])) { - $query->where('chan_code', 'like', '%' . $filters['chan_code'] . '%'); - } - if (!empty($filters['chan_name'])) { - $query->where('chan_name', 'like', '%' . $filters['chan_name'] . '%'); - } - - return $query; - } -} diff --git a/app/repositories/PaymentMethodRepository.php b/app/repositories/PaymentMethodRepository.php deleted file mode 100644 index b523f77..0000000 --- a/app/repositories/PaymentMethodRepository.php +++ /dev/null @@ -1,66 +0,0 @@ -model->newQuery() - ->where('status', 1) - ->orderBy('sort', 'asc') - ->get() - ->toArray(); - } - - public function findByCode(string $methodCode): ?PaymentMethod - { - return $this->model->newQuery() - ->where('type', $methodCode) - ->where('status', 1) - ->first(); - } - - /** - * 后台按 code 查询(不过滤状态) - */ - public function findAnyByCode(string $methodCode): ?PaymentMethod - { - return $this->model->newQuery() - ->where('type', $methodCode) - ->first(); - } - - /** - * 后台列表:支持筛选与排序 - */ - public function searchPaginate(array $filters = [], int $page = 1, int $pageSize = 10) - { - $query = $this->model->newQuery(); - - if (($filters['status'] ?? '') !== '' && $filters['status'] !== null) { - $query->where('status', (int)$filters['status']); - } - if (!empty($filters['method_code'])) { - $query->where('type', 'like', '%' . $filters['method_code'] . '%'); - } - if (!empty($filters['method_name'])) { - $query->where('name', 'like', '%' . $filters['method_name'] . '%'); - } - - $query->orderBy('sort', 'asc')->orderByDesc('id'); - - return $query->paginate($pageSize, ['*'], 'page', $page); - } -} diff --git a/app/repositories/PaymentNotifyTaskRepository.php b/app/repositories/PaymentNotifyTaskRepository.php deleted file mode 100644 index 57b4cb2..0000000 --- a/app/repositories/PaymentNotifyTaskRepository.php +++ /dev/null @@ -1,34 +0,0 @@ -model->newQuery() - ->where('order_id', $orderId) - ->first(); - } - - public function getPendingRetryTasks(int $limit = 100): array - { - return $this->model->newQuery() - ->where('status', PaymentNotifyTask::STATUS_PENDING) - ->where('next_retry_at', '<=', date('Y-m-d H:i:s')) - ->limit($limit) - ->get() - ->toArray(); - } -} diff --git a/app/repositories/PaymentOrderRepository.php b/app/repositories/PaymentOrderRepository.php deleted file mode 100644 index 1281abc..0000000 --- a/app/repositories/PaymentOrderRepository.php +++ /dev/null @@ -1,197 +0,0 @@ -model->newQuery() - ->where('order_id', $orderId) - ->first(); - } - - /** - * 根据商户订单号查询(幂等校验) - */ - public function findByMchNo(int $merchantId, int $merchantAppId, string $mchOrderNo): ?PaymentOrder - { - return $this->model->newQuery() - ->where('merchant_id', $merchantId) - ->where('merchant_app_id', $merchantAppId) - ->where('mch_order_no', $mchOrderNo) - ->first(); - } - - public function updateStatus(string $orderId, int $status, array $extra = []): bool - { - $data = array_merge(['status' => $status], $extra); - $order = $this->findByOrderId($orderId); - return $order ? $this->updateById($order->id, $data) : false; - } - - public function updateChannelInfo(string $orderId, string $chanOrderNo, string $chanTradeNo = ''): bool - { - $order = $this->findByOrderId($orderId); - if (!$order) { - return false; - } - $data = ['chan_order_no' => $chanOrderNo]; - if ($chanTradeNo !== '') { - $data['chan_trade_no'] = $chanTradeNo; - } - return $this->updateById($order->id, $data); - } - - /** - * 后台订单列表:支持筛选与模糊搜索 - */ - public function searchPaginate(array $filters = [], int $page = 1, int $pageSize = 10) - { - $query = $this->buildSearchQuery($filters); - $query->orderByDesc('id'); - return $query->paginate($pageSize, ['*'], 'page', $page); - } - - public function searchList(array $filters = [], int $limit = 5000) - { - return $this->buildSearchQuery($filters) - ->orderByDesc('id') - ->limit($limit) - ->get(); - } - - public function aggregateByChannel(array $channelIds = [], array $filters = []): array - { - if (empty($channelIds)) { - return []; - } - - $query = $this->model->newQuery() - ->selectRaw( - 'channel_id, - COUNT(*) AS total_orders, - SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_orders, - SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS pending_orders, - SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS fail_orders, - SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS closed_orders, - COALESCE(SUM(amount), 0) AS total_amount, - COALESCE(SUM(CASE WHEN status = ? THEN amount ELSE 0 END), 0) AS success_amount, - SUM(CASE WHEN DATE(created_at) = CURDATE() THEN 1 ELSE 0 END) AS today_orders, - COALESCE(SUM(CASE WHEN DATE(created_at) = CURDATE() THEN amount ELSE 0 END), 0) AS today_amount, - SUM(CASE WHEN DATE(created_at) = CURDATE() AND status = ? THEN 1 ELSE 0 END) AS today_success_orders, - COALESCE(SUM(CASE WHEN DATE(created_at) = CURDATE() AND status = ? THEN amount ELSE 0 END), 0) AS today_success_amount, - MAX(created_at) AS last_order_at, - MAX(CASE WHEN status = ? THEN pay_at ELSE NULL END) AS last_success_at', - [ - self::STATUS_SUCCESS, - self::STATUS_PENDING, - self::STATUS_FAIL, - self::STATUS_CLOSED, - self::STATUS_SUCCESS, - self::STATUS_SUCCESS, - self::STATUS_SUCCESS, - self::STATUS_SUCCESS, - ] - ) - ->whereIn('channel_id', $channelIds); - - if (!empty($filters['merchant_id'])) { - $query->where('merchant_id', (int)$filters['merchant_id']); - } - if (!empty($filters['merchant_app_id'])) { - $query->where('merchant_app_id', (int)$filters['merchant_app_id']); - } - if (!empty($filters['method_id'])) { - $query->where('method_id', (int)$filters['method_id']); - } - if (!empty($filters['created_from'])) { - $query->where('created_at', '>=', $filters['created_from']); - } - if (!empty($filters['created_to'])) { - $query->where('created_at', '<=', $filters['created_to']); - } - - $rows = $query->groupBy('channel_id')->get(); - - $result = []; - foreach ($rows as $row) { - $result[(int)$row->channel_id] = $row->toArray(); - } - - return $result; - } - - private function buildSearchQuery(array $filters = []) - { - $query = $this->model->newQuery(); - - if (!empty($filters['merchant_id'])) { - $query->where('merchant_id', (int)$filters['merchant_id']); - } - if (!empty($filters['merchant_app_id'])) { - $query->where('merchant_app_id', (int)$filters['merchant_app_id']); - } - if (!empty($filters['method_id'])) { - $query->where('method_id', (int)$filters['method_id']); - } - if (!empty($filters['channel_id'])) { - $query->where('channel_id', (int)$filters['channel_id']); - } - if (!empty($filters['route_policy_name'])) { - $query->whereRaw( - "JSON_UNQUOTE(JSON_EXTRACT(extra, '$.routing.policy.policy_name')) like ?", - ['%' . $filters['route_policy_name'] . '%'] - ); - } - if (($filters['route_state'] ?? '') !== '' && $filters['route_state'] !== null) { - $routeState = (string)$filters['route_state']; - if ($routeState === 'error') { - $query->whereRaw("JSON_EXTRACT(extra, '$.route_error') IS NOT NULL"); - } elseif ($routeState === 'none') { - $query->whereRaw("JSON_EXTRACT(extra, '$.route_error') IS NULL"); - $query->whereRaw( - "(JSON_UNQUOTE(JSON_EXTRACT(extra, '$.routing.source')) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(extra, '$.routing.source')) = '')" - ); - } else { - $query->whereRaw( - "JSON_EXTRACT(extra, '$.route_error') IS NULL AND JSON_UNQUOTE(JSON_EXTRACT(extra, '$.routing.source')) = ?", - [$routeState] - ); - } - } - if (($filters['status'] ?? '') !== '' && $filters['status'] !== null) { - $query->where('status', (int)$filters['status']); - } - if (!empty($filters['order_id'])) { - $query->where('order_id', 'like', '%' . $filters['order_id'] . '%'); - } - if (!empty($filters['mch_order_no'])) { - $query->where('mch_order_no', 'like', '%' . $filters['mch_order_no'] . '%'); - } - if (!empty($filters['created_from'])) { - $query->where('created_at', '>=', $filters['created_from']); - } - if (!empty($filters['created_to'])) { - $query->where('created_at', '<=', $filters['created_to']); - } - - return $query; - } -} diff --git a/app/repositories/PaymentPluginRepository.php b/app/repositories/PaymentPluginRepository.php deleted file mode 100644 index 34f324c..0000000 --- a/app/repositories/PaymentPluginRepository.php +++ /dev/null @@ -1,96 +0,0 @@ -model->newQuery() - ->where('status', 1) - ->get([ - 'code', - 'name', - 'class_name', - 'pay_types', - 'transfer_types', - 'config_schema', - ]); - } - - public function findActiveByCode(string $pluginCode): ?PaymentPlugin - { - return $this->model->newQuery() - ->where('code', $pluginCode) - ->where('status', 1) - ->first(); - } - - /** - * 后台按编码查询(不过滤状态) - */ - public function findByCode(string $pluginCode): ?PaymentPlugin - { - return $this->model->newQuery() - ->where('code', $pluginCode) - ->first(); - } - - /** - * 后台列表:支持筛选与模糊搜索 - */ - public function searchPaginate(array $filters = [], int $page = 1, int $pageSize = 10) - { - $query = $this->model->newQuery(); - - if (($filters['status'] ?? '') !== '' && $filters['status'] !== null) { - $query->where('status', (int)$filters['status']); - } - if (!empty($filters['plugin_code'])) { - $query->where('code', 'like', '%' . $filters['plugin_code'] . '%'); - } - if (!empty($filters['plugin_name'])) { - $query->where('name', 'like', '%' . $filters['plugin_name'] . '%'); - } - - $query->orderByDesc('created_at'); - return $query->paginate($pageSize, ['*'], 'page', $page); - } - - /** - * 后台保存:存在则更新,不存在则创建 - */ - public function upsertByCode(string $pluginCode, array $data): PaymentPlugin - { - $row = $this->findByCode($pluginCode); - if ($row) { - $this->model->newQuery() - ->where('code', $pluginCode) - ->update($data); - return $this->findByCode($pluginCode) ?: $row; - } - - $data['code'] = $pluginCode; - /** @var PaymentPlugin $created */ - $created = $this->create($data); - return $created; - } - - public function updateStatus(string $pluginCode, int $status): bool - { - return (bool)$this->model->newQuery() - ->where('code', $pluginCode) - ->update(['status' => $status]); - } -} diff --git a/app/repositories/SystemConfigRepository.php b/app/repositories/SystemConfigRepository.php deleted file mode 100644 index a7c809d..0000000 --- a/app/repositories/SystemConfigRepository.php +++ /dev/null @@ -1,157 +0,0 @@ - - */ - protected function loadAllToCache(): array - { - // 优先从 webman/cache 获取 - $cached = Cache::get(self::CACHE_KEY_ALL_CONFIG); - if (is_array($cached)) { - return $cached; - } - - // 缓存不存在时从数据库加载 - $configs = $this->model - ->newQuery() - ->get(['config_key', 'config_value']); - - $result = []; - foreach ($configs as $config) { - $result[$config->config_key] = $config->config_value; - } - - // 写入缓存(不过期,除非显式清理) - Cache::set(self::CACHE_KEY_ALL_CONFIG, $result); - - return $result; - } - - /** - * 清空缓存(供事件调用) - */ - public static function clearCache(): void - { - Cache::delete(self::CACHE_KEY_ALL_CONFIG); - } - - /** - * 重新从数据库加载缓存(供事件调用) - */ - public function reloadCache(): void - { - Cache::delete(self::CACHE_KEY_ALL_CONFIG); - $this->loadAllToCache(); - } - - /** - * 根据配置键名查询配置值 - * - * @param string $configKey - * @return string|null - */ - public function getValueByKey(string $configKey): ?string - { - $all = $this->loadAllToCache(); - - return $all[$configKey] ?? null; - } - - /** - * 根据配置键名数组批量查询配置 - * - * @param array $configKeys - * @return array 返回 ['config_key' => 'config_value'] 格式的数组 - */ - public function getValuesByKeys(array $configKeys): array - { - if (empty($configKeys)) { - return []; - } - - $all = $this->loadAllToCache(); - - $result = []; - foreach ($configKeys as $key) { - if (array_key_exists($key, $all)) { - $result[$key] = $all[$key]; - } - } - - return $result; - } - - /** - * 根据配置键名更新或创建配置 - * - * @param string $configKey - * @param string $configValue - * @return bool - */ - public function updateOrCreate(string $configKey, string $configValue): bool - { - $this->model - ->newQuery() - ->updateOrCreate( - ['config_key' => $configKey], - ['config_value' => $configValue] - ); - - // 通过事件通知重新加载缓存 - Event::emit('system.config.updated', null); - - return true; - } - - /** - * 批量更新或创建配置 - * - * @param array $configs 格式:['config_key' => 'config_value'] - * @return bool - */ - public function batchUpdateOrCreate(array $configs): bool - { - if (empty($configs)) { - return true; - } - - foreach ($configs as $configKey => $configValue) { - $this->model - ->newQuery() - ->updateOrCreate( - ['config_key' => $configKey], - ['config_value' => $configValue] - ); - } - - // 批量更新后只触发一次事件,通知重新加载缓存 - Event::emit('system.config.updated', null); - - return true; - } -} - diff --git a/app/repository/account/balance/MerchantAccountRepository.php b/app/repository/account/balance/MerchantAccountRepository.php new file mode 100644 index 0000000..352c523 --- /dev/null +++ b/app/repository/account/balance/MerchantAccountRepository.php @@ -0,0 +1,52 @@ +model->newQuery() + ->where('merchant_id', $merchantId) + ->first($columns); + } + + /** + * 根据商户 ID 加锁查询余额账户。 + */ + public function findForUpdateByMerchantId(int $merchantId, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->lockForUpdate() + ->first($columns); + } + + /** + * 统计商户是否存在资金账户。 + */ + public function countByMerchantId(int $merchantId): int + { + return (int) $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->count(); + } +} + diff --git a/app/repository/account/ledger/MerchantAccountLedgerRepository.php b/app/repository/account/ledger/MerchantAccountLedgerRepository.php new file mode 100644 index 0000000..f48debd --- /dev/null +++ b/app/repository/account/ledger/MerchantAccountLedgerRepository.php @@ -0,0 +1,78 @@ +model->newQuery() + ->where('idempotency_key', $idempotencyKey) + ->first($columns); + } + + /** + * 根据追踪号查询流水记录。 + */ + public function findByTraceNo(string $traceNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('trace_no', $traceNo) + ->orderByDesc('id') + ->first($columns); + } + + /** + * 查询指定追踪号的流水列表。 + */ + public function listByTraceNo(string $traceNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('trace_no', $traceNo) + ->orderByDesc('id') + ->get($columns); + } + + /** + * 查询商户指定业务类型和业务单号的流水列表。 + */ + /** + * 查询指定业务单号的流水列表。 + */ + public function listByBizNo(string $bizNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('biz_no', $bizNo) + ->orderByDesc('id') + ->get($columns); + } + + public function listByMerchantAndBiz(int $merchantId, int $bizType, string $bizNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->where('biz_type', $bizType) + ->where('biz_no', $bizNo) + ->orderByDesc('id') + ->get($columns); + } +} + + diff --git a/app/repository/file/FileRecordRepository.php b/app/repository/file/FileRecordRepository.php new file mode 100644 index 0000000..d1b5197 --- /dev/null +++ b/app/repository/file/FileRecordRepository.php @@ -0,0 +1,24 @@ +find($id, $columns); + + return $model instanceof FileRecord ? $model : null; + } +} diff --git a/app/repository/merchant/base/MerchantGroupRepository.php b/app/repository/merchant/base/MerchantGroupRepository.php new file mode 100644 index 0000000..ee325dd --- /dev/null +++ b/app/repository/merchant/base/MerchantGroupRepository.php @@ -0,0 +1,47 @@ +model->newQuery() + ->where('status', 1) + ->orderBy('id', 'asc') + ->get($columns); + } + + /** + * 判断分组名称是否已存在。 + */ + public function existsByGroupName(string $groupName, int $ignoreId = 0): bool + { + $query = $this->model->newQuery() + ->where('group_name', $groupName); + + if ($ignoreId > 0) { + $query->where('id', '<>', $ignoreId); + } + + return $query->exists(); + } +} + diff --git a/app/repository/merchant/base/MerchantPolicyRepository.php b/app/repository/merchant/base/MerchantPolicyRepository.php new file mode 100644 index 0000000..45de936 --- /dev/null +++ b/app/repository/merchant/base/MerchantPolicyRepository.php @@ -0,0 +1,32 @@ +model->newQuery() + ->where('merchant_id', $merchantId) + ->first($columns); + } +} + + diff --git a/app/repository/merchant/base/MerchantRepository.php b/app/repository/merchant/base/MerchantRepository.php new file mode 100644 index 0000000..3a98407 --- /dev/null +++ b/app/repository/merchant/base/MerchantRepository.php @@ -0,0 +1,43 @@ +model->newQuery() + ->where('merchant_no', $merchantNo) + ->first($columns); + } + + /** + * 获取所有启用的商户。 + */ + public function enabledList(array $columns = ['*']) + { + return $this->model->newQuery() + ->where('status', 1) + ->orderBy('id', 'desc') + ->get($columns); + } +} + + diff --git a/app/repository/merchant/credential/MerchantApiCredentialRepository.php b/app/repository/merchant/credential/MerchantApiCredentialRepository.php new file mode 100644 index 0000000..8c869fa --- /dev/null +++ b/app/repository/merchant/credential/MerchantApiCredentialRepository.php @@ -0,0 +1,41 @@ +model->newQuery() + ->where('merchant_id', $merchantId) + ->first($columns); + } + + /** + * 统计商户是否已开通 API 凭证。 + */ + public function countByMerchantId(int $merchantId): int + { + return (int) $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->count(); + } +} + diff --git a/app/repository/ops/log/ChannelNotifyLogRepository.php b/app/repository/ops/log/ChannelNotifyLogRepository.php new file mode 100644 index 0000000..25c9143 --- /dev/null +++ b/app/repository/ops/log/ChannelNotifyLogRepository.php @@ -0,0 +1,44 @@ +model->newQuery() + ->where('notify_no', $notifyNo) + ->first($columns); + } + + /** + * 根据渠道、通知类型和业务单号查询重复通知记录。 + */ + public function findDuplicate(int $channelId, int $notifyType, string $bizNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('channel_id', $channelId) + ->where('notify_type', $notifyType) + ->where('biz_no', $bizNo) + ->first($columns); + } +} + + diff --git a/app/repository/ops/log/PayCallbackLogRepository.php b/app/repository/ops/log/PayCallbackLogRepository.php new file mode 100644 index 0000000..dacc66c --- /dev/null +++ b/app/repository/ops/log/PayCallbackLogRepository.php @@ -0,0 +1,33 @@ +model->newQuery() + ->where('pay_no', $payNo) + ->orderByDesc('id') + ->get($columns); + } +} + + diff --git a/app/repository/ops/stat/ChannelDailyStatRepository.php b/app/repository/ops/stat/ChannelDailyStatRepository.php new file mode 100644 index 0000000..4255e85 --- /dev/null +++ b/app/repository/ops/stat/ChannelDailyStatRepository.php @@ -0,0 +1,33 @@ +model->newQuery() + ->where('channel_id', $channelId) + ->where('stat_date', $statDate) + ->first($columns); + } +} + + diff --git a/app/repository/payment/config/PaymentChannelRepository.php b/app/repository/payment/config/PaymentChannelRepository.php new file mode 100644 index 0000000..c51db94 --- /dev/null +++ b/app/repository/payment/config/PaymentChannelRepository.php @@ -0,0 +1,81 @@ +model->newQuery() + ->where('merchant_id', $merchantId) + ->where('status', 1) + ->orderBy('sort_no') + ->get($columns); + } + + /** + * 根据商户 ID 和通道 ID 查询通道。 + */ + public function findByMerchantAndId(int $merchantId, int $channelId, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->whereKey($channelId) + ->first($columns); + } + + /** + * 判断通道名称是否已存在。 + */ + public function existsByName(string $name, int $ignoreId = 0): bool + { + $query = $this->model->newQuery() + ->where('name', $name); + + if ($ignoreId > 0) { + $query->where('id', '<>', $ignoreId); + } + + return $query->exists(); + } + + /** + * 统计商户名下的支付通道概览。 + */ + public function summaryByMerchantId(int $merchantId): object + { + return $this->model->newQuery() + ->selectRaw('COUNT(*) AS total_count') + ->selectRaw('SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS enabled_count') + ->selectRaw('SUM(CASE WHEN channel_mode = 1 THEN 1 ELSE 0 END) AS self_count') + ->where('merchant_id', $merchantId) + ->first(); + } + + /** + * 统计商户下的支付通道数量。 + */ + public function countByMerchantId(int $merchantId): int + { + return (int) $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->count(); + } +} diff --git a/app/repository/payment/config/PaymentPluginConfRepository.php b/app/repository/payment/config/PaymentPluginConfRepository.php new file mode 100644 index 0000000..50f6f1d --- /dev/null +++ b/app/repository/payment/config/PaymentPluginConfRepository.php @@ -0,0 +1,33 @@ +model->newQuery() + ->where('plugin_code', $pluginCode) + ->orderByDesc('id') + ->first($columns); + } +} + + diff --git a/app/repository/payment/config/PaymentPluginRepository.php b/app/repository/payment/config/PaymentPluginRepository.php new file mode 100644 index 0000000..8e74629 --- /dev/null +++ b/app/repository/payment/config/PaymentPluginRepository.php @@ -0,0 +1,43 @@ +model->newQuery() + ->whereKey($code) + ->first($columns); + } + + /** + * 获取所有启用的支付插件。 + */ + public function enabledList(array $columns = ['*']) + { + return $this->model->newQuery() + ->where('status', 1) + ->orderBy('code', 'asc') + ->get($columns); + } +} + + diff --git a/app/repository/payment/config/PaymentPollGroupBindRepository.php b/app/repository/payment/config/PaymentPollGroupBindRepository.php new file mode 100644 index 0000000..4ea63f0 --- /dev/null +++ b/app/repository/payment/config/PaymentPollGroupBindRepository.php @@ -0,0 +1,57 @@ +model->newQuery() + ->where('merchant_group_id', $merchantGroupId) + ->where('pay_type_id', $payTypeId) + ->where('status', 1) + ->first($columns); + } + + /** + * 查询商户分组下的路由绑定概览。 + */ + public function listSummaryByMerchantGroupId(int $merchantGroupId) + { + return $this->model->newQuery() + ->from('ma_payment_poll_group_bind as b') + ->leftJoin('ma_payment_type as t', 'b.pay_type_id', '=', 't.id') + ->leftJoin('ma_payment_poll_group as p', 'b.poll_group_id', '=', 'p.id') + ->where('b.merchant_group_id', $merchantGroupId) + ->orderBy('b.id') + ->get([ + 'b.id', + 'b.pay_type_id', + 'b.poll_group_id', + 'b.status', + 'b.remark', + 't.code as pay_type_code', + 't.name as pay_type_name', + 'p.group_name as poll_group_name', + 'p.route_mode', + ]); + } +} + diff --git a/app/repository/payment/config/PaymentPollGroupChannelRepository.php b/app/repository/payment/config/PaymentPollGroupChannelRepository.php new file mode 100644 index 0000000..db2d99c --- /dev/null +++ b/app/repository/payment/config/PaymentPollGroupChannelRepository.php @@ -0,0 +1,49 @@ +model->newQuery() + ->where('poll_group_id', $pollGroupId) + ->where('status', 1) + ->orderBy('sort_no') + ->get($columns); + } + + /** + * 清空轮询组下其他默认通道标记。 + */ + public function clearDefaultExcept(int $pollGroupId, int $ignoreId = 0): int + { + $query = $this->model->newQuery() + ->where('poll_group_id', $pollGroupId) + ->where('is_default', 1); + + if ($ignoreId > 0) { + $query->where('id', '<>', $ignoreId); + } + + return (int) $query->update(['is_default' => 0]); + } +} + diff --git a/app/repository/payment/config/PaymentPollGroupRepository.php b/app/repository/payment/config/PaymentPollGroupRepository.php new file mode 100644 index 0000000..69c012d --- /dev/null +++ b/app/repository/payment/config/PaymentPollGroupRepository.php @@ -0,0 +1,35 @@ +model->newQuery() + ->where('group_name', $groupName); + + if ($ignoreId > 0) { + $query->where('id', '<>', $ignoreId); + } + + return $query->exists(); + } +} diff --git a/app/repository/payment/config/PaymentTypeRepository.php b/app/repository/payment/config/PaymentTypeRepository.php new file mode 100644 index 0000000..306eb39 --- /dev/null +++ b/app/repository/payment/config/PaymentTypeRepository.php @@ -0,0 +1,43 @@ +model->newQuery() + ->where('status', 1) + ->orderBy('sort_no') + ->get($columns); + } + + /** + * 根据支付方式编码查询字典。 + */ + public function findByCode(string $code, array $columns = ['*']): ?PaymentType + { + return $this->model->newQuery() + ->where('code', $code) + ->first($columns); + } +} + + diff --git a/app/repository/payment/notify/NotifyTaskRepository.php b/app/repository/payment/notify/NotifyTaskRepository.php new file mode 100644 index 0000000..47ee71e --- /dev/null +++ b/app/repository/payment/notify/NotifyTaskRepository.php @@ -0,0 +1,43 @@ +model->newQuery() + ->where('notify_no', $notifyNo) + ->first($columns); + } + + /** + * 查询可重试的通知任务列表。 + */ + public function listRetryable(int $status, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('status', $status) + ->orderBy('next_retry_at') + ->get($columns); + } +} + + diff --git a/app/repository/payment/settlement/SettlementItemRepository.php b/app/repository/payment/settlement/SettlementItemRepository.php new file mode 100644 index 0000000..5ee8456 --- /dev/null +++ b/app/repository/payment/settlement/SettlementItemRepository.php @@ -0,0 +1,33 @@ +model->newQuery() + ->where('settle_no', $settleNo) + ->orderBy('id') + ->get($columns); + } +} + + diff --git a/app/repository/payment/settlement/SettlementOrderRepository.php b/app/repository/payment/settlement/SettlementOrderRepository.php new file mode 100644 index 0000000..abb3862 --- /dev/null +++ b/app/repository/payment/settlement/SettlementOrderRepository.php @@ -0,0 +1,114 @@ +model->newQuery() + ->where('settle_no', $settleNo) + ->first($columns); + } + + /** + * 根据追踪号查询清算单。 + */ + public function findByTraceNo(string $traceNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('trace_no', $traceNo) + ->first($columns); + } + + /** + * 根据追踪号查询清结算单列表。 + */ + public function listByTraceNo(string $traceNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('trace_no', $traceNo) + ->orderByDesc('id') + ->get($columns); + } + + /** + * 根据商户、通道和清算周期查询清算单。 + */ + public function findByCycle(int $merchantId, int $channelId, int $cycleType, string $cycleKey, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->where('channel_id', $channelId) + ->where('cycle_type', $cycleType) + ->where('cycle_key', $cycleKey) + ->first($columns); + } + + /** + * 根据清算单号加锁查询清算单。 + */ + public function findForUpdateBySettleNo(string $settleNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('settle_no', $settleNo) + ->lockForUpdate() + ->first($columns); + } + + /** + * 根据追踪号加锁查询清算单。 + */ + public function findForUpdateByTraceNo(string $traceNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('trace_no', $traceNo) + ->lockForUpdate() + ->first($columns); + } + + /** + * 查询商户最近清算单列表,用于总览展示。 + */ + public function recentByMerchantId(int $merchantId, int $limit = 5) + { + return $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->orderByDesc('id') + ->limit(max(1, $limit)) + ->get([ + 'settle_no', + 'net_amount', + 'status', + 'cycle_key', + 'created_at', + ]); + } + + /** + * 统计商户下的清算单数量。 + */ + public function countByMerchantId(int $merchantId): int + { + return (int) $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->count(); + } +} diff --git a/app/repository/payment/trade/BizOrderRepository.php b/app/repository/payment/trade/BizOrderRepository.php new file mode 100644 index 0000000..ff1036f --- /dev/null +++ b/app/repository/payment/trade/BizOrderRepository.php @@ -0,0 +1,107 @@ +model->newQuery() + ->where('biz_no', $bizNo) + ->first($columns); + } + + /** + * 根据追踪号查询业务订单。 + */ + public function findByTraceNo(string $traceNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('trace_no', $traceNo) + ->first($columns); + } + + /** + * 根据商户 ID 和商户订单号查询业务订单。 + */ + public function findByMerchantAndOrderNo(int $merchantId, string $merchantOrderNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->where('merchant_order_no', $merchantOrderNo) + ->first($columns); + } + + /** + * 根据业务单号查询当前有效的业务订单。 + */ + public function findActiveByBizNo(string $bizNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('biz_no', $bizNo) + ->whereIn('status', [0, 1]) + ->first($columns); + } + + /** + * 根据业务单号加锁查询业务订单。 + */ + public function findForUpdateByBizNo(string $bizNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('biz_no', $bizNo) + ->lockForUpdate() + ->first($columns); + } + + /** + * 根据追踪号加锁查询业务订单。 + */ + public function findForUpdateByTraceNo(string $traceNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('trace_no', $traceNo) + ->lockForUpdate() + ->first($columns); + } + + /** + * 根据商户 ID 和商户订单号加锁查询业务订单。 + */ + public function findForUpdateByMerchantAndOrderNo(int $merchantId, string $merchantOrderNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->where('merchant_order_no', $merchantOrderNo) + ->lockForUpdate() + ->first($columns); + } + + /** + * 统计商户下的业务订单数量。 + */ + public function countByMerchantId(int $merchantId): int + { + return (int) $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->count(); + } +} + diff --git a/app/repository/payment/trade/PayOrderRepository.php b/app/repository/payment/trade/PayOrderRepository.php new file mode 100644 index 0000000..c162b64 --- /dev/null +++ b/app/repository/payment/trade/PayOrderRepository.php @@ -0,0 +1,143 @@ +model->newQuery() + ->where('pay_no', $payNo) + ->first($columns); + } + + /** + * 根据追踪号查询支付单。 + */ + public function findByTraceNo(string $traceNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('trace_no', $traceNo) + ->first($columns); + } + + /** + * 根据追踪号查询支付单列表。 + */ + public function listByTraceNo(string $traceNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('trace_no', $traceNo) + ->orderByDesc('attempt_no') + ->orderByDesc('id') + ->get($columns); + } + + /** + * 根据业务单号查询支付单列表。 + */ + public function listByBizNo(string $bizNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('biz_no', $bizNo) + ->orderByDesc('attempt_no') + ->orderByDesc('id') + ->get($columns); + } + + /** + * 根据业务单号查询最新支付单。 + */ + public function findLatestByBizNo(string $bizNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('biz_no', $bizNo) + ->orderByDesc('attempt_no') + ->first($columns); + } + + /** + * 根据商户和渠道请求号查询支付单。 + */ + public function findByChannelRequestNo(int $merchantId, string $channelRequestNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->where('channel_request_no', $channelRequestNo) + ->first($columns); + } + + /** + * 根据支付单号加锁查询支付单。 + */ + public function findForUpdateByPayNo(string $payNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('pay_no', $payNo) + ->lockForUpdate() + ->first($columns); + } + + /** + * 根据追踪号加锁查询支付单。 + */ + public function findForUpdateByTraceNo(string $traceNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('trace_no', $traceNo) + ->lockForUpdate() + ->first($columns); + } + + /** + * 根据业务单号加锁查询最新支付单。 + */ + public function findLatestForUpdateByBizNo(string $bizNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('biz_no', $bizNo) + ->orderByDesc('attempt_no') + ->lockForUpdate() + ->first($columns); + } + + /** + * 查询商户最近支付单列表,用于总览展示。 + */ + public function recentByMerchantId(int $merchantId, int $limit = 5) + { + return $this->model->newQuery() + ->from('ma_pay_order as po') + ->leftJoin('ma_payment_type as t', 'po.pay_type_id', '=', 't.id') + ->leftJoin('ma_payment_channel as c', 'po.channel_id', '=', 'c.id') + ->where('po.merchant_id', $merchantId) + ->orderByDesc('po.id') + ->limit(max(1, $limit)) + ->get([ + 'po.pay_no', + 'po.pay_amount', + 'po.status', + 'po.created_at', + 't.name as pay_type_name', + 'c.name as channel_name', + ]); + } +} + diff --git a/app/repository/payment/trade/RefundOrderRepository.php b/app/repository/payment/trade/RefundOrderRepository.php new file mode 100644 index 0000000..0eeac47 --- /dev/null +++ b/app/repository/payment/trade/RefundOrderRepository.php @@ -0,0 +1,127 @@ +model->newQuery() + ->where('refund_no', $refundNo) + ->first($columns); + } + + /** + * 根据追踪号查询退款单。 + */ + public function findByTraceNo(string $traceNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('trace_no', $traceNo) + ->first($columns); + } + + /** + * 根据追踪号查询退款单列表。 + */ + public function listByTraceNo(string $traceNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('trace_no', $traceNo) + ->orderByDesc('id') + ->get($columns); + } + + /** + * 根据业务单号查询退款单列表。 + */ + public function listByBizNo(string $bizNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('biz_no', $bizNo) + ->orderByDesc('id') + ->get($columns); + } + + /** + * 根据商户退款单号查询退款单。 + */ + public function findByMerchantRefundNo(int $merchantId, string $merchantRefundNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->where('merchant_refund_no', $merchantRefundNo) + ->first($columns); + } + + /** + * 根据支付单号查询退款单。 + */ + public function findByPayNo(string $payNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('pay_no', $payNo) + ->first($columns); + } + + /** + * 根据退款单号加锁查询退款单。 + */ + public function findForUpdateByRefundNo(string $refundNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('refund_no', $refundNo) + ->lockForUpdate() + ->first($columns); + } + + /** + * 根据追踪号加锁查询退款单。 + */ + public function findForUpdateByTraceNo(string $traceNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('trace_no', $traceNo) + ->lockForUpdate() + ->first($columns); + } + + /** + * 根据支付单号加锁查询退款单。 + */ + public function findForUpdateByPayNo(string $payNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('pay_no', $payNo) + ->lockForUpdate() + ->first($columns); + } + + /** + * 统计商户下的退款订单数量。 + */ + public function countByMerchantId(int $merchantId): int + { + return (int) $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->count(); + } +} + diff --git a/app/repository/system/config/SystemConfigRepository.php b/app/repository/system/config/SystemConfigRepository.php new file mode 100644 index 0000000..f979ede --- /dev/null +++ b/app/repository/system/config/SystemConfigRepository.php @@ -0,0 +1,20 @@ +model->newQuery() + ->where('username', $username) + ->first($columns); + } +} + + diff --git a/app/route/admin.php b/app/route/admin.php new file mode 100644 index 0000000..4425dcf --- /dev/null +++ b/app/route/admin.php @@ -0,0 +1,175 @@ +name('adminApiAuthLogin')->setParams(['real_name' => '管理员登录']); + + Route::group('', function () { + Route::post('/logout', [AuthController::class, 'logout'])->name('adminApiAuthLogout')->setParams(['real_name' => '退出登录']); + Route::get('/user/profile', [AuthController::class, 'profile'])->name('adminApiUserProfile')->setParams(['real_name' => '当前用户资料']); + + Route::get('/merchants', [MerchantController::class, 'index'])->name('adminApiMerchantsIndex')->setParams(['real_name' => '商户列表']); + Route::get('/merchants/options', [MerchantController::class, 'options'])->name('adminApiMerchantsOptions')->setParams(['real_name' => '商户选项']); + Route::get('/merchants/select-options', [MerchantController::class, 'selectOptions'])->name('adminApiMerchantsSelectOptions')->setParams(['real_name' => '商户选择选项']); + Route::get('/merchants/{id}', [MerchantController::class, 'show'])->name('adminApiMerchantsShow')->setParams(['real_name' => '商户详情']); + Route::get('/merchants/{id}/overview', [MerchantController::class, 'overview'])->name('adminApiMerchantsOverview')->setParams(['real_name' => '商户总览']); + Route::post('/merchants', [MerchantController::class, 'store'])->name('adminApiMerchantsStore')->setParams(['real_name' => '新增商户']); + Route::put('/merchants/{id}', [MerchantController::class, 'update'])->name('adminApiMerchantsUpdate')->setParams(['real_name' => '更新商户']); + Route::delete('/merchants/{id}', [MerchantController::class, 'destroy'])->name('adminApiMerchantsDestroy')->setParams(['real_name' => '删除商户']); + Route::post('/merchants/{id}/reset-password', [MerchantController::class, 'resetPassword'])->name('adminApiMerchantsResetPassword')->setParams(['real_name' => '重置商户密码']); + Route::post('/merchants/{id}/issue-credential', [MerchantController::class, 'issueCredential'])->name('adminApiMerchantsIssueCredential')->setParams(['real_name' => '生成或重置接口凭证']); + + Route::get('/admin-users', [AdminUserController::class, 'index'])->name('adminApiAdminUsersIndex')->setParams(['real_name' => '管理员列表']); + Route::get('/admin-users/{id}', [AdminUserController::class, 'show'])->name('adminApiAdminUsersShow')->setParams(['real_name' => '管理员详情']); + Route::post('/admin-users', [AdminUserController::class, 'store'])->name('adminApiAdminUsersStore')->setParams(['real_name' => '新增管理员']); + Route::put('/admin-users/{id}', [AdminUserController::class, 'update'])->name('adminApiAdminUsersUpdate')->setParams(['real_name' => '更新管理员']); + Route::delete('/admin-users/{id}', [AdminUserController::class, 'destroy'])->name('adminApiAdminUsersDestroy')->setParams(['real_name' => '删除管理员']); + + + Route::get('/merchant-api-credentials', [MerchantApiCredentialController::class, 'index'])->name('adminApiMerchantApiCredentialsIndex')->setParams(['real_name' => '商户接口凭证列表']); + Route::get('/merchant-api-credentials/{id}', [MerchantApiCredentialController::class, 'show'])->name('adminApiMerchantApiCredentialsShow')->setParams(['real_name' => '商户接口凭证详情']); + Route::post('/merchant-api-credentials', [MerchantApiCredentialController::class, 'store'])->name('adminApiMerchantApiCredentialsStore')->setParams(['real_name' => '开通商户接口凭证']); + Route::put('/merchant-api-credentials/{id}', [MerchantApiCredentialController::class, 'update'])->name('adminApiMerchantApiCredentialsUpdate')->setParams(['real_name' => '更新商户接口凭证']); + Route::delete('/merchant-api-credentials/{id}', [MerchantApiCredentialController::class, 'destroy'])->name('adminApiMerchantApiCredentialsDestroy')->setParams(['real_name' => '删除商户接口凭证']); + + Route::get('/merchant-groups', [MerchantGroupController::class, 'index'])->name('adminApiMerchantGroupsIndex')->setParams(['real_name' => '商户分组列表']); + Route::get('/merchant-groups/options', [MerchantGroupController::class, 'options'])->name('adminApiMerchantGroupsOptions')->setParams(['real_name' => '商户分组选项']); + Route::get('/merchant-groups/{id}', [MerchantGroupController::class, 'show'])->name('adminApiMerchantGroupsShow')->setParams(['real_name' => '商户分组详情']); + Route::post('/merchant-groups', [MerchantGroupController::class, 'store'])->name('adminApiMerchantGroupsStore')->setParams(['real_name' => '新增商户分组']); + Route::put('/merchant-groups/{id}', [MerchantGroupController::class, 'update'])->name('adminApiMerchantGroupsUpdate')->setParams(['real_name' => '更新商户分组']); + Route::delete('/merchant-groups/{id}', [MerchantGroupController::class, 'destroy'])->name('adminApiMerchantGroupsDestroy')->setParams(['real_name' => '删除商户分组']); + + Route::get('/merchant-policies', [MerchantPolicyController::class, 'index'])->name('adminApiMerchantPoliciesIndex')->setParams(['real_name' => '商户策略列表']); + Route::get('/merchant-policies/{merchantId}', [MerchantPolicyController::class, 'show'])->name('adminApiMerchantPoliciesShow')->setParams(['real_name' => '商户策略详情']); + Route::post('/merchant-policies', [MerchantPolicyController::class, 'store'])->name('adminApiMerchantPoliciesStore')->setParams(['real_name' => '新增商户策略']); + Route::put('/merchant-policies/{merchantId}', [MerchantPolicyController::class, 'update'])->name('adminApiMerchantPoliciesUpdate')->setParams(['real_name' => '更新商户策略']); + Route::delete('/merchant-policies/{merchantId}', [MerchantPolicyController::class, 'destroy'])->name('adminApiMerchantPoliciesDestroy')->setParams(['real_name' => '删除商户策略']); + + Route::get('/payment-types', [PaymentTypeController::class, 'index'])->name('adminApiPaymentTypesIndex')->setParams(['real_name' => '支付方式列表']); + Route::get('/payment-types/options', [PaymentTypeController::class, 'options'])->name('adminApiPaymentTypesOptions')->setParams(['real_name' => '支付方式选项']); + Route::get('/payment-types/{id}', [PaymentTypeController::class, 'show'])->name('adminApiPaymentTypesShow')->setParams(['real_name' => '支付方式详情']); + Route::post('/payment-types', [PaymentTypeController::class, 'store'])->name('adminApiPaymentTypesStore')->setParams(['real_name' => '新增支付方式']); + Route::put('/payment-types/{id}', [PaymentTypeController::class, 'update'])->name('adminApiPaymentTypesUpdate')->setParams(['real_name' => '更新支付方式']); + Route::delete('/payment-types/{id}', [PaymentTypeController::class, 'destroy'])->name('adminApiPaymentTypesDestroy')->setParams(['real_name' => '删除支付方式']); + + Route::get('/payment-plugins', [PaymentPluginController::class, 'index'])->name('adminApiPaymentPluginsIndex')->setParams(['real_name' => '支付插件列表']); + Route::get('/payment-plugins/options', [PaymentPluginController::class, 'options'])->name('adminApiPaymentPluginsOptions')->setParams(['real_name' => '支付插件选项']); + Route::get('/payment-plugins/select-options', [PaymentPluginController::class, 'selectOptions'])->name('adminApiPaymentPluginsSelectOptions')->setParams(['real_name' => '支付插件选择项']); + Route::get('/payment-plugins/channel-options', [PaymentPluginController::class, 'channelOptions'])->name('adminApiPaymentPluginsChannelOptions')->setParams(['real_name' => '支付插件通道选项']); + Route::get('/payment-plugins/{code}/schema', [PaymentPluginController::class, 'schema'])->name('adminApiPaymentPluginsSchema')->setParams(['real_name' => '支付插件配置结构']); + Route::get('/payment-plugins/{code}', [PaymentPluginController::class, 'show'])->name('adminApiPaymentPluginsShow')->setParams(['real_name' => '支付插件详情']); + Route::post('/payment-plugins/refresh', [PaymentPluginController::class, 'refresh'])->name('adminApiPaymentPluginsRefresh')->setParams(['real_name' => '刷新支付插件']); + Route::put('/payment-plugins/{code}', [PaymentPluginController::class, 'update'])->name('adminApiPaymentPluginsUpdate')->setParams(['real_name' => '更新支付插件']); + + Route::get('/payment-plugin-confs', [PaymentPluginConfController::class, 'index'])->name('adminApiPaymentPluginConfsIndex')->setParams(['real_name' => '支付插件配置列表']); + Route::get('/payment-plugin-confs/options', [PaymentPluginConfController::class, 'options'])->name('adminApiPaymentPluginConfsOptions')->setParams(['real_name' => '支付插件配置选项']); + Route::get('/payment-plugin-confs/select-options', [PaymentPluginConfController::class, 'selectOptions'])->name('adminApiPaymentPluginConfsSelectOptions')->setParams(['real_name' => '支付插件配置选择项']); + Route::get('/payment-plugin-confs/{id}', [PaymentPluginConfController::class, 'show'])->name('adminApiPaymentPluginConfsShow')->setParams(['real_name' => '支付插件配置详情']); + Route::post('/payment-plugin-confs', [PaymentPluginConfController::class, 'store'])->name('adminApiPaymentPluginConfsStore')->setParams(['real_name' => '新增支付插件配置']); + Route::put('/payment-plugin-confs/{id}', [PaymentPluginConfController::class, 'update'])->name('adminApiPaymentPluginConfsUpdate')->setParams(['real_name' => '更新支付插件配置']); + Route::delete('/payment-plugin-confs/{id}', [PaymentPluginConfController::class, 'destroy'])->name('adminApiPaymentPluginConfsDestroy')->setParams(['real_name' => '删除支付插件配置']); + + Route::get('/payment-channels', [PaymentChannelController::class, 'index'])->name('adminApiPaymentChannelsIndex')->setParams(['real_name' => '支付通道列表']); + Route::get('/payment-channels/options', [PaymentChannelController::class, 'options'])->name('adminApiPaymentChannelsOptions')->setParams(['real_name' => '支付通道选项']); + Route::get('/payment-channels/select-options', [PaymentChannelController::class, 'selectOptions'])->name('adminApiPaymentChannelsSelectOptions')->setParams(['real_name' => '支付通道选择项']); + Route::get('/payment-channels/route-options', [PaymentChannelController::class, 'routeOptions'])->name('adminApiPaymentChannelsRouteOptions')->setParams(['real_name' => '支付通道路由选项']); + Route::get('/payment-channels/{id}', [PaymentChannelController::class, 'show'])->name('adminApiPaymentChannelsShow')->setParams(['real_name' => '支付通道详情']); + Route::post('/payment-channels', [PaymentChannelController::class, 'store'])->name('adminApiPaymentChannelsStore')->setParams(['real_name' => '新增支付通道']); + Route::put('/payment-channels/{id}', [PaymentChannelController::class, 'update'])->name('adminApiPaymentChannelsUpdate')->setParams(['real_name' => '更新支付通道']); + Route::delete('/payment-channels/{id}', [PaymentChannelController::class, 'destroy'])->name('adminApiPaymentChannelsDestroy')->setParams(['real_name' => '删除支付通道']); + + Route::get('/payment-poll-groups', [PaymentPollGroupController::class, 'index'])->name('adminApiPaymentPollGroupsIndex')->setParams(['real_name' => '轮询组列表']); + Route::get('/payment-poll-groups/options', [PaymentPollGroupController::class, 'options'])->name('adminApiPaymentPollGroupsOptions')->setParams(['real_name' => '轮询组选项']); + Route::get('/payment-poll-groups/{id}', [PaymentPollGroupController::class, 'show'])->name('adminApiPaymentPollGroupsShow')->setParams(['real_name' => '轮询组详情']); + Route::post('/payment-poll-groups', [PaymentPollGroupController::class, 'store'])->name('adminApiPaymentPollGroupsStore')->setParams(['real_name' => '新增轮询组']); + Route::put('/payment-poll-groups/{id}', [PaymentPollGroupController::class, 'update'])->name('adminApiPaymentPollGroupsUpdate')->setParams(['real_name' => '更新轮询组']); + Route::delete('/payment-poll-groups/{id}', [PaymentPollGroupController::class, 'destroy'])->name('adminApiPaymentPollGroupsDestroy')->setParams(['real_name' => '删除轮询组']); + + Route::get('/payment-poll-group-channels', [PaymentPollGroupChannelController::class, 'index'])->name('adminApiPaymentPollGroupChannelsIndex')->setParams(['real_name' => '轮询组通道列表']); + Route::get('/payment-poll-group-channels/{id}', [PaymentPollGroupChannelController::class, 'show'])->name('adminApiPaymentPollGroupChannelsShow')->setParams(['real_name' => '轮询组通道详情']); + Route::post('/payment-poll-group-channels', [PaymentPollGroupChannelController::class, 'store'])->name('adminApiPaymentPollGroupChannelsStore')->setParams(['real_name' => '新增轮询组通道']); + Route::put('/payment-poll-group-channels/{id}', [PaymentPollGroupChannelController::class, 'update'])->name('adminApiPaymentPollGroupChannelsUpdate')->setParams(['real_name' => '更新轮询组通道']); + Route::delete('/payment-poll-group-channels/{id}', [PaymentPollGroupChannelController::class, 'destroy'])->name('adminApiPaymentPollGroupChannelsDestroy')->setParams(['real_name' => '删除轮询组通道']); + + Route::get('/payment-poll-group-binds', [PaymentPollGroupBindController::class, 'index'])->name('adminApiPaymentPollGroupBindsIndex')->setParams(['real_name' => '轮询组绑定列表']); + Route::get('/payment-poll-group-binds/{id}', [PaymentPollGroupBindController::class, 'show'])->name('adminApiPaymentPollGroupBindsShow')->setParams(['real_name' => '轮询组绑定详情']); + Route::post('/payment-poll-group-binds', [PaymentPollGroupBindController::class, 'store'])->name('adminApiPaymentPollGroupBindsStore')->setParams(['real_name' => '新增轮询组绑定']); + Route::put('/payment-poll-group-binds/{id}', [PaymentPollGroupBindController::class, 'update'])->name('adminApiPaymentPollGroupBindsUpdate')->setParams(['real_name' => '更新轮询组绑定']); + Route::delete('/payment-poll-group-binds/{id}', [PaymentPollGroupBindController::class, 'destroy'])->name('adminApiPaymentPollGroupBindsDestroy')->setParams(['real_name' => '删除轮询组绑定']); + + Route::get('/routes/resolve', [RouteController::class, 'resolve'])->name('adminApiRoutesResolve')->setParams(['real_name' => '解析路由']); + + Route::get('/channel-daily-stats', [ChannelDailyStatController::class, 'index'])->name('adminApiChannelDailyStatsIndex')->setParams(['real_name' => '渠道日统计列表']); + Route::get('/channel-daily-stats/{id}', [ChannelDailyStatController::class, 'show'])->name('adminApiChannelDailyStatsShow')->setParams(['real_name' => '渠道日统计详情']); + + Route::get('/file-asset/options', [FileRecordController::class, 'options'])->name('adminApiFileRecordOptions')->setParams(['real_name' => '文件选项']); + Route::get('/file-asset', [FileRecordController::class, 'index'])->name('adminApiFileRecordIndex')->setParams(['real_name' => '文件列表']); + Route::post('/file-asset/upload', [FileRecordController::class, 'upload'])->name('adminApiFileRecordUpload')->setParams(['real_name' => '上传文件']); + Route::post('/file-asset/import-remote', [FileRecordController::class, 'importRemote'])->name('adminApiFileRecordImportRemote')->setParams(['real_name' => '导入远程文件']); + Route::get('/file-asset/{id}/preview', [FileRecordController::class, 'preview'])->name('adminApiFileRecordPreview')->setParams(['real_name' => '文件预览']); + Route::get('/file-asset/{id}/download', [FileRecordController::class, 'download'])->name('adminApiFileRecordDownload')->setParams(['real_name' => '文件下载']); + Route::get('/file-asset/{id}', [FileRecordController::class, 'show'])->name('adminApiFileRecordShow')->setParams(['real_name' => '文件详情']); + Route::delete('/file-asset/{id}', [FileRecordController::class, 'destroy'])->name('adminApiFileRecordDestroy')->setParams(['real_name' => '删除文件']); + + Route::get('/pay-orders', [PayOrderController::class, 'index'])->name('adminApiPayOrdersIndex')->setParams(['real_name' => '支付订单列表']); + Route::get('/refund-orders', [RefundOrderController::class, 'index'])->name('adminApiRefundOrdersIndex')->setParams(['real_name' => '退款订单列表']); + Route::get('/refund-orders/{refundNo}', [RefundOrderController::class, 'show'])->name('adminApiRefundOrdersShow')->setParams(['real_name' => '退款订单详情']); + Route::post('/refund-orders/{refundNo}/retry', [RefundOrderController::class, 'retry'])->name('adminApiRefundOrdersRetry')->setParams(['real_name' => '退款重试']); + + Route::get('/settlement-orders', [SettlementOrderController::class, 'index'])->name('adminApiSettlementOrdersIndex')->setParams(['real_name' => '清算订单列表']); + Route::get('/settlement-orders/{settleNo}', [SettlementOrderController::class, 'show'])->name('adminApiSettlementOrdersShow')->setParams(['real_name' => '清算订单详情']); + + Route::get('/channel-notify-logs', [ChannelNotifyLogController::class, 'index'])->name('adminApiChannelNotifyLogsIndex')->setParams(['real_name' => '渠道通知日志列表']); + Route::get('/channel-notify-logs/{id}', [ChannelNotifyLogController::class, 'show'])->name('adminApiChannelNotifyLogsShow')->setParams(['real_name' => '渠道通知日志详情']); + + Route::get('/pay-callback-logs', [PayCallbackLogController::class, 'index'])->name('adminApiPayCallbackLogsIndex')->setParams(['real_name' => '支付回调日志列表']); + Route::get('/pay-callback-logs/{id}', [PayCallbackLogController::class, 'show'])->name('adminApiPayCallbackLogsShow')->setParams(['real_name' => '支付回调日志详情']); + + Route::get('/merchant-accounts', [MerchantAccountController::class, 'index'])->name('adminApiMerchantAccountsIndex')->setParams(['real_name' => '资金账户列表']); + Route::get('/merchant-accounts/summary', [MerchantAccountController::class, 'summary'])->name('adminApiMerchantAccountsSummary')->setParams(['real_name' => '资金账户总览']); + Route::get('/merchant-accounts/{id}', [MerchantAccountController::class, 'show'])->name('adminApiMerchantAccountsShow')->setParams(['real_name' => '资金账户详情']); + + Route::get('/account-ledgers', [MerchantAccountLedgerController::class, 'index'])->name('adminApiAccountLedgersIndex')->setParams(['real_name' => '资金流水列表']); + Route::get('/account-ledgers/{id}', [MerchantAccountLedgerController::class, 'show'])->name('adminApiAccountLedgersShow')->setParams(['real_name' => '资金流水详情']); + + Route::get('/system/menu-tree', [SystemController::class, 'menuTree'])->name('adminApiMenuTree')->setParams(['real_name' => '菜单树']); + Route::get('/system/dict-items', [SystemController::class, 'dictItems'])->name('adminApiDictItems')->setParams(['real_name' => '字典项']); + + Route::get('/system-config-pages', [SystemConfigPageController::class, 'index'])->name('adminApiSystemConfigPagesIndex')->setParams(['real_name' => '系统配置页面列表']); + Route::get('/system-config-pages/{groupCode}', [SystemConfigPageController::class, 'show'])->name('adminApiSystemConfigPagesShow')->setParams(['real_name' => '系统配置页面详情']); + Route::post('/system-config-pages/{groupCode}', [SystemConfigPageController::class, 'store'])->name('adminApiSystemConfigPagesStore')->setParams(['real_name' => '保存系统配置页面']); + + })->middleware([AdminAuthMiddleware::class]); +})->middleware([Cors::class]); diff --git a/app/route/api.php b/app/route/api.php new file mode 100644 index 0000000..16716c9 --- /dev/null +++ b/app/route/api.php @@ -0,0 +1,60 @@ +name('epaySubmit')->setParams(['real_name' => 'Epay页面跳转支付']); + Route::post('/mapi.php', [EpayController::class, 'mapi'])->name('epayMapi')->setParams(['real_name' => 'Epay接口支付']); + Route::any('/api.php', [EpayController::class, 'api'])->name('epayApi')->setParams(['real_name' => 'Epay标准API']); +})->middleware([Cors::class]); + +Route::group('/api', function () { + Route::group('/pay', function () { + Route::post('/prepare', [PayController::class, 'prepare'])->name('payPrepare')->setParams(['real_name' => '支付预下单']); + Route::get('/{payNo}', [PayController::class, 'show'])->name('payDetail')->setParams(['real_name' => '查询支付单']); + Route::post('/{payNo}/close', [PayController::class, 'close'])->name('payClose')->setParams(['real_name' => '关闭支付单']); + Route::post('/{payNo}/timeout', [PayController::class, 'timeout'])->name('payTimeout')->setParams(['real_name' => '支付超时']); + Route::any('/{payNo}/callback', [PayController::class, 'callback'])->name('payChannelCallback')->setParams(['real_name' => '第三方支付回调']); + Route::post('/callback/mock', [PayController::class, 'callback'])->name('payCallbackMock')->setParams(['real_name' => '支付回调模拟入口']); + }); + + Route::group('/refunds', function () { + Route::post('/', [RefundController::class, 'create'])->name('refundCreate')->setParams(['real_name' => '创建退款单']); + Route::get('/{refundNo}', [RefundController::class, 'show'])->name('refundDetail')->setParams(['real_name' => '查询退款单']); + Route::post('/{refundNo}/processing', [RefundController::class, 'processing'])->name('refundProcessing')->setParams(['real_name' => '退款处理中']); + Route::post('/{refundNo}/retry', [RefundController::class, 'retry'])->name('refundRetry')->setParams(['real_name' => '退款重试']); + Route::post('/{refundNo}/fail', [RefundController::class, 'markFail'])->name('refundFail')->setParams(['real_name' => '退款失败']); + }); + + Route::group('/settlements', function () { + Route::post('/', [SettlementController::class, 'create'])->name('settlementCreate')->setParams(['real_name' => '创建清结算单']); + Route::get('/{settleNo}', [SettlementController::class, 'show'])->name('settlementDetail')->setParams(['real_name' => '查询清结算单']); + Route::post('/{settleNo}/complete', [SettlementController::class, 'complete'])->name('settlementComplete')->setParams(['real_name' => '清结算成功']); + Route::post('/{settleNo}/fail', [SettlementController::class, 'failSettlement'])->name('settlementFail')->setParams(['real_name' => '清结算失败']); + }); + + Route::group('/routes', function () { + Route::get('/resolve', [RouteController::class, 'resolve'])->name('routeResolve')->setParams(['real_name' => '解析路由']); + }); + + Route::group('/traces', function () { + Route::get('/{traceNo}', [TraceController::class, 'show'])->name('traceDetail')->setParams(['real_name' => '追踪查询']); + }); + + Route::group('/notify', function () { + Route::post('/channel', [NotifyController::class, 'channel'])->name('notifyChannel')->setParams(['real_name' => '渠道通知']); + Route::post('/merchant', [NotifyController::class, 'merchant'])->name('notifyMerchant')->setParams(['real_name' => '商户通知']); + }); +})->middleware([Cors::class]); diff --git a/app/route/mer.php b/app/route/mer.php new file mode 100644 index 0000000..7a7a487 --- /dev/null +++ b/app/route/mer.php @@ -0,0 +1,43 @@ +name('merchantApiAuthLogin')->setParams(['real_name' => '商户登录']); + + Route::group('', function () { + Route::post('/logout', [AuthController::class, 'logout'])->name('merchantApiAuthLogout')->setParams(['real_name' => '退出登录']); + Route::get('/user/profile', [AuthController::class, 'profile'])->name('merchantApiUserProfile')->setParams(['real_name' => '当前登录账号']); + Route::get('/merchant/profile', [MerchantPortalController::class, 'profile'])->name('merchantApiPortalProfile')->setParams(['real_name' => '商户资料']); + Route::put('/merchant/profile', [MerchantPortalController::class, 'updateProfile'])->name('merchantApiPortalProfileUpdate')->setParams(['real_name' => '更新商户资料']); + Route::post('/merchant/change-password', [MerchantPortalController::class, 'changePassword'])->name('merchantApiPortalChangePassword')->setParams(['real_name' => '修改登录密码']); + Route::get('/my-channels', [MerchantPortalController::class, 'myChannels'])->name('merchantApiPortalMyChannels')->setParams(['real_name' => '我的通道']); + Route::get('/route-preview', [MerchantPortalController::class, 'routePreview'])->name('merchantApiPortalRoutePreview')->setParams(['real_name' => '路由预览']); + Route::get('/api-credential', [MerchantPortalController::class, 'apiCredential'])->name('merchantApiPortalCredential')->setParams(['real_name' => '接口凭证']); + Route::post('/api-credential/issue-credential', [MerchantPortalController::class, 'issueCredential'])->name('merchantApiPortalIssueCredential')->setParams(['real_name' => '生成或重置接口凭证']); + Route::get('/settlement-records', [MerchantPortalController::class, 'settlementRecords'])->name('merchantApiPortalSettlementRecords')->setParams(['real_name' => '清算记录']); + Route::get('/settlement-records/{settleNo}', [MerchantPortalController::class, 'settlementRecordShow'])->name('merchantApiPortalSettlementRecordShow')->setParams(['real_name' => '清算记录详情']); + Route::get('/withdrawable-balance', [MerchantPortalController::class, 'withdrawableBalance'])->name('merchantApiPortalWithdrawableBalance')->setParams(['real_name' => '可提现余额']); + Route::get('/balance-flows', [MerchantPortalController::class, 'balanceFlows'])->name('merchantApiPortalBalanceFlows')->setParams(['real_name' => '资金流水']); + Route::get('/pay-orders', [PayOrderController::class, 'index'])->name('merchantApiPayOrdersIndex')->setParams(['real_name' => '支付订单']); + Route::get('/refund-orders', [RefundOrderController::class, 'index'])->name('merchantApiRefundOrdersIndex')->setParams(['real_name' => '退款订单']); + Route::get('/refund-orders/{refundNo}', [RefundOrderController::class, 'show'])->name('merchantApiRefundOrdersShow')->setParams(['real_name' => '退款订单详情']); + Route::post('/refund-orders/{refundNo}/retry', [RefundOrderController::class, 'retry']) + ->name('merchantApiRefundOrdersRetry') + ->setParams(['real_name' => '退款重试']); + + Route::get('/system/menu-tree', [SystemController::class, 'menuTree'])->name('merchantApiMenuTree')->setParams(['real_name' => '菜单树']); + Route::get('/system/dict-items', [SystemController::class, 'dictItems'])->name('merchantApiDictItems')->setParams(['real_name' => '字典项']); + })->middleware([MerchantAuthMiddleware::class]); +})->middleware([Cors::class]); diff --git a/app/routes/admin.php b/app/routes/admin.php deleted file mode 100644 index a92f6ba..0000000 --- a/app/routes/admin.php +++ /dev/null @@ -1,230 +0,0 @@ -name('captcha') - ->setParams(['real_name' => 'adminCaptcha']); - Route::post('/login', [AuthController::class, 'login']) - ->name('login') - ->setParams(['real_name' => 'adminLogin']); - - Route::group('', function () { - Route::get('/user/getUserInfo', [AdminController::class, 'getUserInfo']) - ->name('getUserInfo') - ->setParams(['real_name' => 'getUserInfo']); - Route::get('/menu/getRouters', [MenuController::class, 'getRouters']) - ->name('getRouters') - ->setParams(['real_name' => 'getRouters']); - - Route::get('/system/getDict[/{code}]', [SystemController::class, 'getDict']) - ->name('getDict') - ->setParams(['real_name' => 'getSystemDict']); - Route::get('/system/base-config/tabs', [SystemController::class, 'getTabsConfig']) - ->name('getTabsConfig') - ->setParams(['real_name' => 'getSystemTabs']); - Route::get('/system/base-config/form/{tabKey}', [SystemController::class, 'getFormConfig']) - ->name('getFormConfig') - ->setParams(['real_name' => 'getSystemForm']); - Route::post('/system/base-config/submit/{tabKey}', [SystemController::class, 'submitConfig']) - ->name('submitConfig') - ->setParams(['real_name' => 'submitSystemConfig']); - Route::get('/system/log/files', [SystemController::class, 'logFiles']) - ->name('systemLogFiles') - ->setParams(['real_name' => 'systemLogFiles']); - Route::get('/system/log/summary', [SystemController::class, 'logSummary']) - ->name('systemLogSummary') - ->setParams(['real_name' => 'systemLogSummary']); - Route::get('/system/log/content', [SystemController::class, 'logContent']) - ->name('systemLogContent') - ->setParams(['real_name' => 'systemLogContent']); - Route::get('/system/notice/overview', [SystemController::class, 'noticeOverview']) - ->name('systemNoticeOverview') - ->setParams(['real_name' => 'systemNoticeOverview']); - Route::post('/system/notice/test', [SystemController::class, 'noticeTest']) - ->name('systemNoticeTest') - ->setParams(['real_name' => 'systemNoticeTest']); - - Route::get('/finance/reconciliation', [FinanceController::class, 'reconciliation']) - ->name('financeReconciliation') - ->setParams(['real_name' => 'financeReconciliation']); - Route::get('/finance/settlement', [FinanceController::class, 'settlement']) - ->name('financeSettlement') - ->setParams(['real_name' => 'financeSettlement']); - Route::get('/finance/batch-settlement', [FinanceController::class, 'batchSettlement']) - ->name('financeBatchSettlement') - ->setParams(['real_name' => 'financeBatchSettlement']); - Route::get('/finance/settlement-record', [FinanceController::class, 'settlementRecord']) - ->name('financeSettlementRecord') - ->setParams(['real_name' => 'financeSettlementRecord']); - Route::get('/finance/split', [FinanceController::class, 'split']) - ->name('financeSplit') - ->setParams(['real_name' => 'financeSplit']); - Route::get('/finance/fee', [FinanceController::class, 'fee']) - ->name('financeFee') - ->setParams(['real_name' => 'financeFee']); - Route::get('/finance/invoice', [FinanceController::class, 'invoice']) - ->name('financeInvoice') - ->setParams(['real_name' => 'financeInvoice']); - - Route::get('/channel/list', [ChannelController::class, 'list']) - ->name('channelList') - ->setParams(['real_name' => 'channelList']); - Route::get('/channel/detail', [ChannelController::class, 'detail']) - ->name('channelDetail') - ->setParams(['real_name' => 'channelDetail']); - Route::get('/channel/monitor', [ChannelController::class, 'monitor']) - ->name('channelMonitor') - ->setParams(['real_name' => 'channelMonitor']); - Route::get('/channel/polling', [ChannelController::class, 'polling']) - ->name('channelPolling') - ->setParams(['real_name' => 'channelPolling']); - Route::get('/channel/policy/list', [ChannelController::class, 'policyList']) - ->name('channelPolicyList') - ->setParams(['real_name' => 'channelPolicyList']); - Route::post('/channel/save', [ChannelController::class, 'save']) - ->name('channelSave') - ->setParams(['real_name' => 'channelSave']); - Route::post('/channel/toggle', [ChannelController::class, 'toggle']) - ->name('channelToggle') - ->setParams(['real_name' => 'channelToggle']); - Route::post('/channel/policy/save', [ChannelController::class, 'policySave']) - ->name('channelPolicySave') - ->setParams(['real_name' => 'channelPolicySave']); - Route::post('/channel/policy/preview', [ChannelController::class, 'policyPreview']) - ->name('channelPolicyPreview') - ->setParams(['real_name' => 'channelPolicyPreview']); - Route::post('/channel/policy/delete', [ChannelController::class, 'policyDelete']) - ->name('channelPolicyDelete') - ->setParams(['real_name' => 'channelPolicyDelete']); - - Route::get('/channel/plugins', [PluginController::class, 'plugins']) - ->name('channelPlugins') - ->setParams(['real_name' => 'channelPlugins']); - Route::get('/channel/plugin/config-schema', [PluginController::class, 'configSchema']) - ->name('channelPluginConfigSchema') - ->setParams(['real_name' => 'channelPluginConfigSchema']); - Route::get('/channel/plugin/products', [PluginController::class, 'products']) - ->name('channelPluginProducts') - ->setParams(['real_name' => 'channelPluginProducts']); - - Route::get('/merchant/list', [MerchantController::class, 'list']) - ->name('merchantList') - ->setParams(['real_name' => 'merchantList']); - Route::get('/merchant/detail', [MerchantController::class, 'detail']) - ->name('merchantDetail') - ->setParams(['real_name' => 'merchantDetail']); - Route::get('/merchant/profile/detail', [MerchantController::class, 'profileDetail']) - ->name('merchantProfileDetail') - ->setParams(['real_name' => 'merchantProfileDetail']); - Route::get('/merchant/statistics', [MerchantController::class, 'statistics']) - ->name('merchantStatistics') - ->setParams(['real_name' => 'merchantStatistics']); - Route::get('/merchant/funds', [MerchantController::class, 'funds']) - ->name('merchantFunds') - ->setParams(['real_name' => 'merchantFunds']); - Route::get('/merchant/audit', [MerchantController::class, 'audit']) - ->name('merchantAudit') - ->setParams(['real_name' => 'merchantAudit']); - Route::get('/merchant/group/list', [MerchantController::class, 'groupList']) - ->name('merchantGroupList') - ->setParams(['real_name' => 'merchantGroupList']); - Route::post('/merchant/group/save', [MerchantController::class, 'groupSave']) - ->name('merchantGroupSave') - ->setParams(['real_name' => 'merchantGroupSave']); - Route::post('/merchant/group/delete', [MerchantController::class, 'groupDelete']) - ->name('merchantGroupDelete') - ->setParams(['real_name' => 'merchantGroupDelete']); - Route::get('/merchant/package/list', [MerchantController::class, 'packageList']) - ->name('merchantPackageList') - ->setParams(['real_name' => 'merchantPackageList']); - Route::post('/merchant/package/save', [MerchantController::class, 'packageSave']) - ->name('merchantPackageSave') - ->setParams(['real_name' => 'merchantPackageSave']); - Route::post('/merchant/package/delete', [MerchantController::class, 'packageDelete']) - ->name('merchantPackageDelete') - ->setParams(['real_name' => 'merchantPackageDelete']); - Route::post('/merchant/save', [MerchantController::class, 'save']) - ->name('merchantSave') - ->setParams(['real_name' => 'merchantSave']); - Route::post('/merchant/profile/save', [MerchantController::class, 'profileSave']) - ->name('merchantProfileSave') - ->setParams(['real_name' => 'merchantProfileSave']); - Route::post('/merchant/audit-action', [MerchantController::class, 'auditAction']) - ->name('merchantAuditAction') - ->setParams(['real_name' => 'merchantAuditAction']); - Route::post('/merchant/toggle', [MerchantController::class, 'toggle']) - ->name('merchantToggle') - ->setParams(['real_name' => 'merchantToggle']); - - Route::get('/merchant-app/list', [MerchantAppController::class, 'list']) - ->name('merchantAppList') - ->setParams(['real_name' => 'merchantAppList']); - Route::get('/merchant-app/detail', [MerchantAppController::class, 'detail']) - ->name('merchantAppDetail') - ->setParams(['real_name' => 'merchantAppDetail']); - Route::get('/merchant-app/config/detail', [MerchantAppController::class, 'configDetail']) - ->name('merchantAppConfigDetail') - ->setParams(['real_name' => 'merchantAppConfigDetail']); - Route::post('/merchant-app/save', [MerchantAppController::class, 'save']) - ->name('merchantAppSave') - ->setParams(['real_name' => 'merchantAppSave']); - Route::post('/merchant-app/config/save', [MerchantAppController::class, 'configSave']) - ->name('merchantAppConfigSave') - ->setParams(['real_name' => 'merchantAppConfigSave']); - Route::post('/merchant-app/reset-secret', [MerchantAppController::class, 'resetSecret']) - ->name('merchantAppResetSecret') - ->setParams(['real_name' => 'merchantAppResetSecret']); - Route::post('/merchant-app/toggle', [MerchantAppController::class, 'toggle']) - ->name('merchantAppToggle') - ->setParams(['real_name' => 'merchantAppToggle']); - - Route::get('/pay-method/list', [PayMethodController::class, 'list']) - ->name('payMethodList') - ->setParams(['real_name' => 'payMethodList']); - Route::post('/pay-method/save', [PayMethodController::class, 'save']) - ->name('payMethodSave') - ->setParams(['real_name' => 'payMethodSave']); - Route::post('/pay-method/toggle', [PayMethodController::class, 'toggle']) - ->name('payMethodToggle') - ->setParams(['real_name' => 'payMethodToggle']); - - Route::get('/pay-plugin/list', [PayPluginController::class, 'list']) - ->name('payPluginList') - ->setParams(['real_name' => 'payPluginList']); - Route::post('/pay-plugin/save', [PayPluginController::class, 'save']) - ->name('payPluginSave') - ->setParams(['real_name' => 'payPluginSave']); - Route::post('/pay-plugin/toggle', [PayPluginController::class, 'toggle']) - ->name('payPluginToggle') - ->setParams(['real_name' => 'payPluginToggle']); - - Route::get('/order/list', [OrderController::class, 'list']) - ->name('orderList') - ->setParams(['real_name' => 'orderList']); - Route::get('/order/export', [OrderController::class, 'export']) - ->name('orderExport') - ->setParams(['real_name' => 'orderExport']); - Route::get('/order/detail', [OrderController::class, 'detail']) - ->name('orderDetail') - ->setParams(['real_name' => 'orderDetail']); - Route::post('/order/refund', [OrderController::class, 'refund']) - ->name('orderRefund') - ->setParams(['real_name' => 'orderRefund']); - })->middleware([AuthMiddleware::class]); -})->middleware([Cors::class]); diff --git a/app/routes/api.php b/app/routes/api.php deleted file mode 100644 index 9dca864..0000000 --- a/app/routes/api.php +++ /dev/null @@ -1,23 +0,0 @@ -transactionRetry(function () use ($merchantId) { + return $this->ensureAccountInCurrentTransaction($merchantId); + }); + } + + /** + * 在当前事务中获取或创建商户账户。 + */ + public function ensureAccountInCurrentTransaction(int $merchantId): MerchantAccount + { + $account = $this->accountRepository->findForUpdateByMerchantId($merchantId); + if ($account) { + return $account; + } + + $this->accountRepository->create([ + 'merchant_id' => $merchantId, + 'available_balance' => 0, + 'frozen_balance' => 0, + ]); + + $account = $this->accountRepository->findForUpdateByMerchantId($merchantId); + if (!$account) { + throw new ValidationException('商户账户创建失败', ['merchant_id' => $merchantId]); + } + + return $account; + } + + public function freezeAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->transactionRetry(function () use ($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo) { + return $this->freezeAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + }); + } + + public function freezeAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + $this->assertPositiveAmount($amount); + if ($idempotencyKey === '') { + throw new ValidationException('幂等键不能为空'); + } + + if ($existing = $this->findLedgerByIdempotencyKey($idempotencyKey)) { + $this->assertLedgerMatch($existing, LedgerConstant::BIZ_TYPE_PAY_FREEZE, $bizNo, $amount, LedgerConstant::DIRECTION_OUT); + return $existing; + } + + $account = $this->ensureAccountInCurrentTransaction($merchantId); + if ((int) $account->available_balance < $amount) { + throw new BalanceInsufficientException($merchantId, $amount, (int) $account->available_balance); + } + + $availableBefore = (int) $account->available_balance; + $frozenBefore = (int) $account->frozen_balance; + + $account->available_balance = $availableBefore - $amount; + $account->frozen_balance = $frozenBefore + $amount; + $account->save(); + + return $this->createLedger([ + 'merchant_id' => $merchantId, + 'biz_type' => LedgerConstant::BIZ_TYPE_PAY_FREEZE, + 'biz_no' => $bizNo, + 'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo), + 'event_type' => LedgerConstant::EVENT_TYPE_CREATE, + 'direction' => LedgerConstant::DIRECTION_OUT, + 'amount' => $amount, + 'available_before' => $availableBefore, + 'available_after' => (int) $account->available_balance, + 'frozen_before' => $frozenBefore, + 'frozen_after' => (int) $account->frozen_balance, + 'idempotency_key' => $idempotencyKey, + 'remark' => $extJson['remark'] ?? '余额冻结', + 'ext_json' => $extJson, + ]); + } + + public function deductFrozenAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->transactionRetry(function () use ($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo) { + return $this->deductFrozenAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + }); + } + + public function deductFrozenAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + $this->assertPositiveAmount($amount); + if ($idempotencyKey === '') { + throw new ValidationException('幂等键不能为空'); + } + + if ($existing = $this->findLedgerByIdempotencyKey($idempotencyKey)) { + $this->assertLedgerMatch($existing, LedgerConstant::BIZ_TYPE_PAY_DEDUCT, $bizNo, $amount, LedgerConstant::DIRECTION_OUT); + return $existing; + } + + $account = $this->ensureAccountInCurrentTransaction($merchantId); + if ((int) $account->frozen_balance < $amount) { + throw new ValidationException('冻结余额不足', [ + 'merchant_id' => $merchantId, + 'amount' => $amount, + 'frozen_balance' => (int) $account->frozen_balance, + ]); + } + + $availableBefore = (int) $account->available_balance; + $frozenBefore = (int) $account->frozen_balance; + + $account->frozen_balance = $frozenBefore - $amount; + $account->save(); + + return $this->createLedger([ + 'merchant_id' => $merchantId, + 'biz_type' => LedgerConstant::BIZ_TYPE_PAY_DEDUCT, + 'biz_no' => $bizNo, + 'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo), + 'event_type' => LedgerConstant::EVENT_TYPE_SUCCESS, + 'direction' => LedgerConstant::DIRECTION_OUT, + 'amount' => $amount, + 'available_before' => $availableBefore, + 'available_after' => (int) $account->available_balance, + 'frozen_before' => $frozenBefore, + 'frozen_after' => (int) $account->frozen_balance, + 'idempotency_key' => $idempotencyKey, + 'remark' => $extJson['remark'] ?? '余额扣减', + 'ext_json' => $extJson, + ]); + } + + public function releaseFrozenAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->transactionRetry(function () use ($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo) { + return $this->releaseFrozenAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + }); + } + + public function releaseFrozenAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + $this->assertPositiveAmount($amount); + if ($idempotencyKey === '') { + throw new ValidationException('幂等键不能为空'); + } + + if ($existing = $this->findLedgerByIdempotencyKey($idempotencyKey)) { + $this->assertLedgerMatch($existing, LedgerConstant::BIZ_TYPE_PAY_RELEASE, $bizNo, $amount, LedgerConstant::DIRECTION_IN); + return $existing; + } + + $account = $this->ensureAccountInCurrentTransaction($merchantId); + if ((int) $account->frozen_balance < $amount) { + throw new ValidationException('冻结余额不足', [ + 'merchant_id' => $merchantId, + 'amount' => $amount, + 'frozen_balance' => (int) $account->frozen_balance, + ]); + } + + $availableBefore = (int) $account->available_balance; + $frozenBefore = (int) $account->frozen_balance; + + $account->available_balance = $availableBefore + $amount; + $account->frozen_balance = $frozenBefore - $amount; + $account->save(); + + return $this->createLedger([ + 'merchant_id' => $merchantId, + 'biz_type' => LedgerConstant::BIZ_TYPE_PAY_RELEASE, + 'biz_no' => $bizNo, + 'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo), + 'event_type' => LedgerConstant::EVENT_TYPE_REVERSE, + 'direction' => LedgerConstant::DIRECTION_IN, + 'amount' => $amount, + 'available_before' => $availableBefore, + 'available_after' => (int) $account->available_balance, + 'frozen_before' => $frozenBefore, + 'frozen_after' => (int) $account->frozen_balance, + 'idempotency_key' => $idempotencyKey, + 'remark' => $extJson['remark'] ?? '冻结余额释放', + 'ext_json' => $extJson, + ]); + } + + public function creditAvailableAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->transactionRetry(function () use ($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo) { + return $this->creditAvailableAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + }); + } + + public function creditAvailableAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + $this->assertPositiveAmount($amount); + if ($idempotencyKey === '') { + throw new ValidationException('幂等键不能为空'); + } + + if ($existing = $this->findLedgerByIdempotencyKey($idempotencyKey)) { + $this->assertLedgerMatch($existing, LedgerConstant::BIZ_TYPE_SETTLEMENT_CREDIT, $bizNo, $amount, LedgerConstant::DIRECTION_IN); + return $existing; + } + + $account = $this->ensureAccountInCurrentTransaction($merchantId); + $availableBefore = (int) $account->available_balance; + $frozenBefore = (int) $account->frozen_balance; + + $account->available_balance = $availableBefore + $amount; + $account->save(); + + return $this->createLedger([ + 'merchant_id' => $merchantId, + 'biz_type' => LedgerConstant::BIZ_TYPE_SETTLEMENT_CREDIT, + 'biz_no' => $bizNo, + 'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo), + 'event_type' => LedgerConstant::EVENT_TYPE_SUCCESS, + 'direction' => LedgerConstant::DIRECTION_IN, + 'amount' => $amount, + 'available_before' => $availableBefore, + 'available_after' => (int) $account->available_balance, + 'frozen_before' => $frozenBefore, + 'frozen_after' => (int) $account->frozen_balance, + 'idempotency_key' => $idempotencyKey, + 'remark' => $extJson['remark'] ?? '清算入账', + 'ext_json' => $extJson, + ]); + } + + public function debitAvailableAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->transactionRetry(function () use ($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo) { + return $this->debitAvailableAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + }); + } + + public function debitAvailableAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + $this->assertPositiveAmount($amount); + if ($idempotencyKey === '') { + throw new ValidationException('幂等键不能为空'); + } + + if ($existing = $this->findLedgerByIdempotencyKey($idempotencyKey)) { + $this->assertLedgerMatch($existing, LedgerConstant::BIZ_TYPE_REFUND_REVERSE, $bizNo, $amount, LedgerConstant::DIRECTION_OUT); + return $existing; + } + + $account = $this->ensureAccountInCurrentTransaction($merchantId); + if ((int) $account->available_balance < $amount) { + throw new BalanceInsufficientException($merchantId, $amount, (int) $account->available_balance); + } + + $availableBefore = (int) $account->available_balance; + $frozenBefore = (int) $account->frozen_balance; + + $account->available_balance = $availableBefore - $amount; + $account->save(); + + return $this->createLedger([ + 'merchant_id' => $merchantId, + 'biz_type' => LedgerConstant::BIZ_TYPE_REFUND_REVERSE, + 'biz_no' => $bizNo, + 'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo), + 'event_type' => LedgerConstant::EVENT_TYPE_REVERSE, + 'direction' => LedgerConstant::DIRECTION_OUT, + 'amount' => $amount, + 'available_before' => $availableBefore, + 'available_after' => (int) $account->available_balance, + 'frozen_before' => $frozenBefore, + 'frozen_after' => (int) $account->frozen_balance, + 'idempotency_key' => $idempotencyKey, + 'remark' => $extJson['remark'] ?? '余额冲减', + 'ext_json' => $extJson, + ]); + } + + private function createLedger(array $data): MerchantAccountLedger + { + $data['ledger_no'] = $data['ledger_no'] ?? $this->generateNo('LG'); + $data['trace_no'] = trim((string) ($data['trace_no'] ?? $data['biz_no'] ?? '')); + $data['created_at'] = $data['created_at'] ?? $this->now(); + + return $this->ledgerRepository->create($data); + } + + private function findLedgerByIdempotencyKey(string $idempotencyKey): ?MerchantAccountLedger + { + return $this->ledgerRepository->findByIdempotencyKey($idempotencyKey); + } + + private function assertPositiveAmount(int $amount): void + { + if ($amount <= 0) { + throw new ValidationException('金额必须大于 0'); + } + } + + private function assertLedgerMatch(MerchantAccountLedger $ledger, int $bizType, string $bizNo, int $amount, int $direction): void + { + if ((int) $ledger->biz_type !== $bizType || (int) $ledger->amount !== $amount || (string) $ledger->biz_no !== $bizNo || (int) $ledger->direction !== $direction) { + throw new ConflictException('幂等冲突', [ + 'ledger_no' => (string) $ledger->ledger_no, + 'biz_type' => $bizType, + 'biz_no' => $bizNo, + ]); + } + } + + private function normalizeTraceNo(string $traceNo, string $bizNo): string + { + $traceNo = trim($traceNo); + if ($traceNo !== '') { + return $traceNo; + } + + return $bizNo; + } +} diff --git a/app/service/account/funds/MerchantAccountQueryService.php b/app/service/account/funds/MerchantAccountQueryService.php new file mode 100644 index 0000000..8a4cd3a --- /dev/null +++ b/app/service/account/funds/MerchantAccountQueryService.php @@ -0,0 +1,160 @@ +accountRepository->query() + ->from('ma_merchant_account as a') + ->leftJoin('ma_merchant as m', 'a.merchant_id', '=', 'm.id') + ->leftJoin('ma_merchant_group as g', 'm.group_id', '=', 'g.id') + ->select([ + 'a.id', + 'a.merchant_id', + 'a.available_balance', + 'a.frozen_balance', + 'a.created_at', + 'a.updated_at', + ]) + ->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no") + ->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name") + ->selectRaw("COALESCE(m.merchant_short_name, '') AS merchant_short_name") + ->selectRaw("COALESCE(g.group_name, '') AS merchant_group_name"); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('m.merchant_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%') + ->orWhere('g.group_name', 'like', '%' . $keyword . '%'); + }); + } + + $merchantId = (string) ($filters['merchant_id'] ?? ''); + if ($merchantId !== '') { + $query->where('a.merchant_id', (int) $merchantId); + } + + $paginator = $query + ->orderByDesc('a.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + $paginator->getCollection()->transform(function ($row) { + $row->available_balance_text = $this->formatAmount((int) $row->available_balance); + $row->frozen_balance_text = $this->formatAmount((int) $row->frozen_balance); + + return $row; + }); + + return $paginator; + } + + /** + * 资金中心概览。 + */ + public function summary(): array + { + $accountStats = $this->accountRepository->query() + ->selectRaw('COUNT(*) AS account_count') + ->selectRaw('SUM(available_balance) AS total_available_balance') + ->selectRaw('SUM(frozen_balance) AS total_frozen_balance') + ->first(); + + $ledgerStats = $this->ledgerRepository->query() + ->selectRaw('COUNT(*) AS ledger_count') + ->first(); + + $totalAvailableBalance = (int) ($accountStats->total_available_balance ?? 0); + $totalFrozenBalance = (int) ($accountStats->total_frozen_balance ?? 0); + + return [ + 'account_count' => (int) ($accountStats->account_count ?? 0), + 'ledger_count' => (int) ($ledgerStats->ledger_count ?? 0), + 'total_available_balance' => $totalAvailableBalance, + 'total_available_balance_text' => $this->formatAmount($totalAvailableBalance), + 'total_frozen_balance' => $totalFrozenBalance, + 'total_frozen_balance_text' => $this->formatAmount($totalFrozenBalance), + ]; + } + + /** + * 获取商户余额快照。 + * + * 用于后台展示和接口返回,不修改任何账户数据。 + */ + public function getBalanceSnapshot(int $merchantId): array + { + $account = $this->accountRepository->findByMerchantId($merchantId); + + if (!$account) { + return [ + 'merchant_id' => $merchantId, + 'available_balance' => 0, + 'frozen_balance' => 0, + ]; + } + + return [ + 'merchant_id' => (int) $account->merchant_id, + 'available_balance' => (int) $account->available_balance, + 'frozen_balance' => (int) $account->frozen_balance, + ]; + } + + /** + * 查询商户账户详情。 + */ + public function findById(int $id): ?MerchantAccount + { + $row = $this->accountRepository->query() + ->from('ma_merchant_account as a') + ->leftJoin('ma_merchant as m', 'a.merchant_id', '=', 'm.id') + ->leftJoin('ma_merchant_group as g', 'm.group_id', '=', 'g.id') + ->select([ + 'a.id', + 'a.merchant_id', + 'a.available_balance', + 'a.frozen_balance', + 'a.created_at', + 'a.updated_at', + ]) + ->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no") + ->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name") + ->selectRaw("COALESCE(m.merchant_short_name, '') AS merchant_short_name") + ->selectRaw("COALESCE(g.group_name, '') AS merchant_group_name") + ->where('a.id', $id) + ->first(); + + if (!$row) { + return null; + } + + $row->available_balance_text = $this->formatAmount((int) $row->available_balance); + $row->frozen_balance_text = $this->formatAmount((int) $row->frozen_balance); + + return $row; + } + +} diff --git a/app/service/account/funds/MerchantAccountService.php b/app/service/account/funds/MerchantAccountService.php new file mode 100644 index 0000000..8abf84e --- /dev/null +++ b/app/service/account/funds/MerchantAccountService.php @@ -0,0 +1,101 @@ +queryService->paginate($filters, $page, $pageSize); + } + + public function summary(): array + { + return $this->queryService->summary(); + } + + public function ensureAccount(int $merchantId): MerchantAccount + { + return $this->commandService->ensureAccount($merchantId); + } + + public function ensureAccountInCurrentTransaction(int $merchantId): MerchantAccount + { + return $this->commandService->ensureAccountInCurrentTransaction($merchantId); + } + + public function freezeAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->commandService->freezeAmount($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + } + + public function freezeAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->commandService->freezeAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + } + + public function deductFrozenAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->commandService->deductFrozenAmount($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + } + + public function deductFrozenAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->commandService->deductFrozenAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + } + + public function releaseFrozenAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->commandService->releaseFrozenAmount($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + } + + public function releaseFrozenAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->commandService->releaseFrozenAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + } + + public function creditAvailableAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->commandService->creditAvailableAmount($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + } + + public function creditAvailableAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->commandService->creditAvailableAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + } + + public function debitAvailableAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->commandService->debitAvailableAmount($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + } + + public function debitAvailableAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->commandService->debitAvailableAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + } + + public function getBalanceSnapshot(int $merchantId): array + { + return $this->queryService->getBalanceSnapshot($merchantId); + } + + public function findById(int $id): ?MerchantAccount + { + return $this->queryService->findById($id); + } +} diff --git a/app/service/account/ledger/MerchantAccountLedgerService.php b/app/service/account/ledger/MerchantAccountLedgerService.php new file mode 100644 index 0000000..ddff10c --- /dev/null +++ b/app/service/account/ledger/MerchantAccountLedgerService.php @@ -0,0 +1,138 @@ +baseQuery(); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('l.ledger_no', 'like', '%' . $keyword . '%') + ->orWhere('l.biz_no', 'like', '%' . $keyword . '%') + ->orWhere('l.trace_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') + ->orWhere('l.idempotency_key', 'like', '%' . $keyword . '%'); + }); + } + + $merchantId = (string) ($filters['merchant_id'] ?? ''); + if ($merchantId !== '') { + $query->where('l.merchant_id', (int) $merchantId); + } + + $bizType = (string) ($filters['biz_type'] ?? ''); + if ($bizType !== '') { + $query->where('l.biz_type', (int) $bizType); + } + + $eventType = (string) ($filters['event_type'] ?? ''); + if ($eventType !== '') { + $query->where('l.event_type', (int) $eventType); + } + + $direction = (string) ($filters['direction'] ?? ''); + if ($direction !== '') { + $query->where('l.direction', (int) $direction); + } + + $paginator = $query + ->orderByDesc('l.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + $paginator->getCollection()->transform(function ($row) { + return $this->decorateRow($row); + }); + + return $paginator; + } + + /** + * 查询流水详情。 + */ + public function findById(int $id): ?MerchantAccountLedger + { + $row = $this->baseQuery() + ->where('l.id', $id) + ->first(); + + return $row ?: null; + } + + /** + * 格式化记录。 + */ + private function decorateRow(object $row): object + { + $row->biz_type_text = (string) (LedgerConstant::bizTypeMap()[(int) $row->biz_type] ?? '未知'); + $row->event_type_text = (string) (LedgerConstant::eventTypeMap()[(int) $row->event_type] ?? '未知'); + $row->direction_text = (string) (LedgerConstant::directionMap()[(int) $row->direction] ?? '未知'); + $row->amount_text = $this->formatAmount((int) $row->amount); + $row->available_before_text = $this->formatAmount((int) $row->available_before); + $row->available_after_text = $this->formatAmount((int) $row->available_after); + $row->frozen_before_text = $this->formatAmount((int) $row->frozen_before); + $row->frozen_after_text = $this->formatAmount((int) $row->frozen_after); + $row->created_at_text = $this->formatDateTime($row->created_at ?? null); + $row->ext_json_text = $this->formatJson($row->ext_json ?? null); + + return $row; + } + + /** + * 构建查询。 + */ + private function baseQuery() + { + return $this->merchantAccountLedgerRepository->query() + ->from('ma_merchant_account_ledger as l') + ->leftJoin('ma_merchant as m', 'l.merchant_id', '=', 'm.id') + ->leftJoin('ma_merchant_group as g', 'm.group_id', '=', 'g.id') + ->select([ + 'l.id', + 'l.ledger_no', + 'l.merchant_id', + 'l.biz_type', + 'l.biz_no', + 'l.trace_no', + 'l.event_type', + 'l.direction', + 'l.amount', + 'l.available_before', + 'l.available_after', + 'l.frozen_before', + 'l.frozen_after', + 'l.idempotency_key', + 'l.remark', + 'l.ext_json', + 'l.created_at', + ]) + ->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no") + ->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name") + ->selectRaw("COALESCE(m.merchant_short_name, '') AS merchant_short_name") + ->selectRaw("COALESCE(g.group_name, '') AS merchant_group_name"); + } + +} diff --git a/app/service/bootstrap/SystemBootstrapService.php b/app/service/bootstrap/SystemBootstrapService.php new file mode 100644 index 0000000..58215a5 --- /dev/null +++ b/app/service/bootstrap/SystemBootstrapService.php @@ -0,0 +1,157 @@ +filterByRoles($this->menuNodes($panel), $roles); + + return $this->normalizeRedirects($this->buildTree($nodes)); + } + + public function getDictItems(?string $code = null): array + { + $items = $this->dictItems(); + $code = trim((string) $code); + if ($code === '') { + return array_values($items); + } + + $codes = array_values(array_filter(array_map('trim', explode(',', $code)))); + if ($codes === []) { + return array_values($items); + } + + if (count($codes) === 1) { + return $items[$codes[0]] ?? []; + } + + return array_values(array_intersect_key($items, array_flip($codes))); + } + + protected function menuNodes(string $panel): array + { + return (array) config("menu.$panel", config('menu.admin', [])); + } + + protected function dictItems(): array + { + return $this->normalizeDictItems((array) config('dict', [])); + } + + protected function normalizeDictItems(array $items): array + { + $normalized = []; + foreach ($items as $key => $item) { + if (!is_array($item)) { + continue; + } + + $code = trim((string) ($item['code'] ?? (is_string($key) ? $key : ''))); + if ($code === '') { + continue; + } + + $list = []; + foreach ((array) ($item['list'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + + $list[] = [ + 'name' => (string) ($row['name'] ?? ''), + 'value' => $row['value'] ?? '', + ]; + } + + $normalized[$code] = [ + 'name' => (string) ($item['name'] ?? $code), + 'code' => $code, + 'description' => (string) ($item['description'] ?? ''), + 'list' => $list, + ]; + } + + return $normalized; + } + + protected function filterByRoles(array $nodes, array $roles): array + { + return array_values(array_filter($nodes, function (array $node) use ($roles): bool { + $metaRoles = (array) ($node['meta']['roles'] ?? []); + if ($metaRoles !== [] && count(array_intersect($metaRoles, $roles)) === 0) { + return false; + } + + if (!empty($node['meta']['disable'])) { + return false; + } + + return true; + })); + } + + protected function buildTree(array $nodes): array + { + $grouped = []; + foreach ($nodes as $node) { + $node['children'] = null; + $parentId = (string) ($node['parentId'] ?? '0'); + $grouped[$parentId][] = $node; + } + + $build = function (string $parentId) use (&$build, &$grouped): array { + $children = $grouped[$parentId] ?? []; + usort($children, function (array $left, array $right): int { + $leftSort = (int) ($left['meta']['sort'] ?? 0); + $rightSort = (int) ($right['meta']['sort'] ?? 0); + + return $leftSort <=> $rightSort; + }); + + foreach ($children as &$child) { + $child['children'] = $build((string) $child['id']); + if ($child['children'] === []) { + $child['children'] = null; + } + } + + return $children; + }; + + return $build('0'); + } + + protected function normalizeRedirects(array $tree): array + { + foreach ($tree as &$node) { + if (!empty($node['children']) && is_array($node['children'])) { + $childPath = $this->firstRenderablePath($node['children']); + if ($childPath !== null) { + $node['redirect'] = $childPath; + } + $node['children'] = $this->normalizeRedirects($node['children']); + } + } + + return $tree; + } + + protected function firstRenderablePath(array $nodes): ?string + { + foreach ($nodes as $node) { + $path = (string) ($node['path'] ?? ''); + if ($path !== '') { + return $path; + } + } + + return null; + } +} + diff --git a/app/service/file/FileRecordCommandService.php b/app/service/file/FileRecordCommandService.php new file mode 100644 index 0000000..344ce43 --- /dev/null +++ b/app/service/file/FileRecordCommandService.php @@ -0,0 +1,281 @@ +assertFileUpload($file); + + $sourcePath = $file->getPathname(); + try { + $scene = $this->storageConfigService->normalizeScene($data['scene'] ?? null, (string) $file->getUploadName(), (string) $file->getUploadMimeType()); + $visibility = $this->storageConfigService->normalizeVisibility($data['visibility'] ?? null, $scene); + $engine = $this->storageConfigService->defaultEngine(); + + $result = $this->storageManager->storeFromPath( + $sourcePath, + (string) $file->getUploadName(), + $scene, + $visibility, + $engine, + null, + 'upload' + ); + + try { + $asset = $this->fileRecordRepository->create([ + 'scene' => (int) $result['scene'], + 'source_type' => (int) $result['source_type'], + 'visibility' => (int) $result['visibility'], + 'storage_engine' => (int) $result['storage_engine'], + 'original_name' => (string) $result['original_name'], + 'file_name' => (string) $result['file_name'], + 'file_ext' => (string) $result['file_ext'], + 'mime_type' => (string) $result['mime_type'], + 'size' => (int) $result['size'], + 'md5' => (string) $result['md5'], + 'object_key' => (string) $result['object_key'], + 'url' => (string) $result['url'], + 'source_url' => (string) ($result['source_url'] ?? ''), + 'created_by' => $createdBy, + 'created_by_name' => $createdByName, + ]); + } catch (\Throwable $e) { + $this->storageManager->delete($result); + throw $e; + } + + return $this->fileRecordQueryService->formatModel($asset); + } finally { + if (is_file($sourcePath)) { + @unlink($sourcePath); + } + } + } + + public function importRemote(string $remoteUrl, array $data, int $createdBy = 0, string $createdByName = ''): array + { + $remoteUrl = trim($remoteUrl); + if ($remoteUrl === '') { + throw new ValidationException('远程图片地址不能为空'); + } + + $download = $this->downloadRemoteFile($remoteUrl, (int) ($data['scene'] ?? 0)); + try { + $scene = $this->storageConfigService->normalizeScene($data['scene'] ?? null, $download['name'], $download['mime_type']); + $visibility = $this->storageConfigService->normalizeVisibility($data['visibility'] ?? null, $scene); + $engine = $this->storageConfigService->defaultEngine(); + + $result = $this->storageManager->storeFromPath( + $download['path'], + $download['name'], + $scene, + $visibility, + $engine, + $remoteUrl, + 'remote_url' + ); + + try { + $asset = $this->fileRecordRepository->create([ + 'scene' => (int) $result['scene'], + 'source_type' => (int) $result['source_type'], + 'visibility' => (int) $result['visibility'], + 'storage_engine' => (int) $result['storage_engine'], + 'original_name' => (string) $result['original_name'], + 'file_name' => (string) $result['file_name'], + 'file_ext' => (string) $result['file_ext'], + 'mime_type' => (string) $result['mime_type'], + 'size' => (int) $result['size'], + 'md5' => (string) $result['md5'], + 'object_key' => (string) $result['object_key'], + 'url' => (string) $result['url'], + 'source_url' => $remoteUrl, + 'created_by' => $createdBy, + 'created_by_name' => $createdByName, + ]); + } catch (\Throwable $e) { + $this->storageManager->delete($result); + throw $e; + } + + return $this->fileRecordQueryService->formatModel($asset); + } finally { + if (is_file($download['path'])) { + @unlink($download['path']); + } + } + } + + public function delete(int $id): bool + { + $asset = $this->fileRecordRepository->findById($id); + if (!$asset) { + return false; + } + + $this->storageManager->delete($this->fileRecordQueryService->formatModel($asset)); + + return $this->fileRecordRepository->deleteById($id); + } + + private function assertFileUpload(UploadFile $file): void + { + if (!$file->isValid()) { + throw new ValidationException('上传文件无效'); + } + + $sizeLimit = $this->storageConfigService->uploadMaxSizeBytes(); + $size = (int) $file->getSize(); + if ($size > $sizeLimit) { + throw new BusinessStateException('文件大小超过系统限制'); + } + + $extension = strtolower((string) $file->getUploadExtension()); + if ($extension === '') { + $extension = strtolower((string) pathinfo((string) $file->getUploadName(), PATHINFO_EXTENSION)); + } + + if ($extension !== '' && !in_array($extension, $this->storageConfigService->allowedExtensions(), true)) { + throw new BusinessStateException('文件类型暂不支持'); + } + } + + private function downloadRemoteFile(string $remoteUrl, int $scene = 0): array + { + if (!filter_var($remoteUrl, FILTER_VALIDATE_URL)) { + throw new ValidationException('远程图片地址格式不正确'); + } + + $scheme = strtolower((string) parse_url($remoteUrl, PHP_URL_SCHEME)); + if (!in_array($scheme, ['http', 'https'], true)) { + throw new ValidationException('仅支持 http 或 https 远程地址'); + } + + $host = (string) parse_url($remoteUrl, PHP_URL_HOST); + if ($host === '') { + throw new ValidationException('远程图片地址格式不正确'); + } + + if (filter_var($host, FILTER_VALIDATE_IP) && Request::isIntranetIp($host)) { + throw new BusinessStateException('远程地址不允许访问内网资源'); + } + + $ip = gethostbyname($host); + if ($ip !== $host && Request::isIntranetIp($ip)) { + throw new BusinessStateException('远程地址不允许访问内网资源'); + } + + $tempPath = tempnam(sys_get_temp_dir(), 'file_asset_'); + if ($tempPath === false) { + throw new BusinessStateException('创建临时文件失败'); + } + + $mimeType = 'application/octet-stream'; + $downloadName = basename((string) parse_url($remoteUrl, PHP_URL_PATH)); + if ($downloadName === '') { + $downloadName = 'remote-file'; + } + + $ch = curl_init($remoteUrl); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + CURLOPT_TIMEOUT => 30, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_USERAGENT => 'MPay File Asset Downloader', + ]); + + $body = curl_exec($ch); + $error = curl_error($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $contentType = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + $effectiveUrl = (string) curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); + + curl_close($ch); + + if ($body === false || $httpCode >= 400) { + @unlink($tempPath); + throw new BusinessStateException($error !== '' ? $error : '远程文件下载失败'); + } + + if ($effectiveUrl !== '') { + $effectiveHost = (string) parse_url($effectiveUrl, PHP_URL_HOST); + if ($effectiveHost !== '') { + $effectiveIp = gethostbyname($effectiveHost); + if ($effectiveIp !== $effectiveHost && Request::isIntranetIp($effectiveIp)) { + @unlink($tempPath); + throw new BusinessStateException('远程地址重定向到了内网资源'); + } + } + } + + if ($contentType !== '') { + $mimeType = trim(explode(';', $contentType)[0]); + } + + if (strlen((string) $body) > $this->storageConfigService->remoteDownloadLimitBytes()) { + @unlink($tempPath); + throw new BusinessStateException('远程文件大小超过系统限制'); + } + + if (file_put_contents($tempPath, (string) $body) === false) { + @unlink($tempPath); + throw new BusinessStateException('远程文件写入失败'); + } + + $size = is_file($tempPath) ? (int) filesize($tempPath) : 0; + $name = $downloadName; + $ext = strtolower((string) pathinfo($name, PATHINFO_EXTENSION)); + if ($ext === '') { + $ext = match (true) { + str_starts_with($mimeType, 'image/jpeg') => 'jpg', + str_starts_with($mimeType, 'image/png') => 'png', + str_starts_with($mimeType, 'image/gif') => 'gif', + str_starts_with($mimeType, 'image/webp') => 'webp', + str_starts_with($mimeType, 'image/svg') => 'svg', + str_starts_with($mimeType, 'text/plain') => 'txt', + str_starts_with($mimeType, 'application/json') => 'json', + str_starts_with($mimeType, 'application/xml') => 'xml', + default => '', + }; + if ($ext !== '') { + $name .= '.' . $ext; + } + } + + return [ + 'path' => $tempPath, + 'name' => $name, + 'mime_type' => $mimeType, + 'size' => $size, + 'scene' => $scene, + ]; + } +} diff --git a/app/service/file/FileRecordQueryService.php b/app/service/file/FileRecordQueryService.php new file mode 100644 index 0000000..8c44c75 --- /dev/null +++ b/app/service/file/FileRecordQueryService.php @@ -0,0 +1,176 @@ +fileRecordRepository->query()->from('ma_file_asset as f'); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('f.original_name', 'like', '%' . $keyword . '%') + ->orWhere('f.file_name', 'like', '%' . $keyword . '%') + ->orWhere('f.object_key', 'like', '%' . $keyword . '%') + ->orWhere('f.source_url', 'like', '%' . $keyword . '%'); + }); + } + + foreach (['scene', 'source_type', 'visibility', 'storage_engine'] as $field) { + if (array_key_exists($field, $filters) && $filters[$field] !== '' && $filters[$field] !== null) { + $query->where('f.' . $field, (int) $filters[$field]); + } + } + + $query->orderByDesc('f.id'); + + $paginator = $query->paginate(max(1, $pageSize), ['f.*'], 'page', max(1, $page)); + $collection = $paginator->getCollection(); + $collection->transform(function ($row): array { + return $this->formatModel($row); + }); + + return $paginator; + } + + public function detail(int $id): array + { + $asset = $this->fileRecordRepository->findById($id); + if (!$asset) { + throw new ResourceNotFoundException('文件不存在', ['id' => $id]); + } + + return $this->formatModel($asset); + } + + public function formatModel(mixed $asset): array + { + $id = (int) $this->field($asset, 'id', 0); + $scene = (int) $this->field($asset, 'scene', FileConstant::SCENE_OTHER); + $visibility = (int) $this->field($asset, 'visibility', FileConstant::VISIBILITY_PRIVATE); + $storageEngine = (int) $this->field($asset, 'storage_engine', FileConstant::STORAGE_LOCAL); + $sourceType = (int) $this->field($asset, 'source_type', FileConstant::SOURCE_UPLOAD); + $size = (int) $this->field($asset, 'size', 0); + $publicUrl = (string) $this->field($asset, 'url', ''); + $previewUrl = $publicUrl !== '' ? $publicUrl : $this->storageManager->temporaryUrl($this->normalizeAsset($asset)); + if ($previewUrl === '' && $id > 0) { + $previewUrl = '/adminapi/file-asset/' . $id . '/preview'; + } + + return [ + 'id' => $id, + 'scene' => $scene, + 'scene_text' => (string) (FileConstant::sceneMap()[$scene] ?? '未知'), + 'source_type' => $sourceType, + 'source_type_text' => (string) (FileConstant::sourceTypeMap()[$sourceType] ?? '未知'), + 'visibility' => $visibility, + 'visibility_text' => (string) (FileConstant::visibilityMap()[$visibility] ?? '未知'), + 'storage_engine' => $storageEngine, + 'storage_engine_text' => (string) (FileConstant::storageEngineMap()[$storageEngine] ?? '未知'), + 'original_name' => (string) $this->field($asset, 'original_name', ''), + 'file_name' => (string) $this->field($asset, 'file_name', ''), + 'file_ext' => (string) $this->field($asset, 'file_ext', ''), + 'mime_type' => (string) $this->field($asset, 'mime_type', ''), + 'size' => $size, + 'size_text' => $this->formatSize($size), + 'md5' => (string) $this->field($asset, 'md5', ''), + 'object_key' => (string) $this->field($asset, 'object_key', ''), + 'source_url' => (string) $this->field($asset, 'source_url', ''), + 'url' => $previewUrl, + 'public_url' => $publicUrl, + 'preview_url' => $previewUrl, + 'download_url' => $id > 0 ? '/adminapi/file-asset/' . $id . '/download' : '', + 'created_by' => (int) $this->field($asset, 'created_by', 0), + 'created_by_name' => (string) $this->field($asset, 'created_by_name', ''), + 'remark' => (string) $this->field($asset, 'remark', ''), + 'is_image' => $scene === FileConstant::SCENE_IMAGE || str_starts_with(strtolower((string) $this->field($asset, 'mime_type', '')), 'image/'), + 'created_at' => $this->formatDateTime($this->field($asset, 'created_at', null)), + 'updated_at' => $this->formatDateTime($this->field($asset, 'updated_at', null)), + ]; + } + + public function options(): array + { + return [ + 'sourceTypes' => $this->toOptions(FileConstant::sourceTypeMap()), + 'visibilities' => $this->toOptions(FileConstant::visibilityMap()), + 'scenes' => $this->toOptions(FileConstant::sceneMap()), + 'storageEngines' => $this->toOptions(FileConstant::storageEngineMap()), + 'selectableStorageEngines' => $this->toOptions(FileConstant::selectableStorageEngineMap()), + ]; + } + + private function formatSize(int $size): string + { + if ($size <= 0) { + return '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $index = 0; + $value = (float) $size; + while ($value >= 1024 && $index < count($units) - 1) { + $value /= 1024; + $index++; + } + + return $index === 0 ? (string) (int) $value . ' ' . $units[$index] : number_format($value, 2) . ' ' . $units[$index]; + } + + private function toOptions(array $map): array + { + $options = []; + foreach ($map as $value => $label) { + $options[] = [ + 'label' => (string) $label, + 'value' => (int) $value, + ]; + } + + return $options; + } + + private function field(mixed $asset, string $key, mixed $default = null): mixed + { + if (is_array($asset)) { + return $asset[$key] ?? $default; + } + + if (is_object($asset) && isset($asset->{$key})) { + return $asset->{$key}; + } + + return $default; + } + + private function normalizeAsset(mixed $asset): array + { + return $this->field($asset, 'id', null) === null ? [] : [ + 'id' => (int) $this->field($asset, 'id', 0), + 'storage_engine' => (int) $this->field($asset, 'storage_engine', FileConstant::STORAGE_LOCAL), + 'visibility' => (int) $this->field($asset, 'visibility', FileConstant::VISIBILITY_PRIVATE), + 'original_name' => (string) $this->field($asset, 'original_name', ''), + 'object_key' => (string) $this->field($asset, 'object_key', ''), + 'source_url' => (string) $this->field($asset, 'source_url', ''), + 'url' => (string) $this->field($asset, 'url', ''), + 'mime_type' => (string) $this->field($asset, 'mime_type', ''), + ]; + } +} diff --git a/app/service/file/FileRecordService.php b/app/service/file/FileRecordService.php new file mode 100644 index 0000000..39861fa --- /dev/null +++ b/app/service/file/FileRecordService.php @@ -0,0 +1,64 @@ +queryService->paginate($filters, $page, $pageSize); + } + + public function detail(int $id): array + { + return $this->queryService->detail($id); + } + + public function options(): array + { + return $this->queryService->options(); + } + + public function upload(UploadFile $file, array $data, int $createdBy = 0, string $createdByName = ''): array + { + return $this->commandService->upload($file, $data, $createdBy, $createdByName); + } + + public function importRemote(string $remoteUrl, array $data, int $createdBy = 0, string $createdByName = ''): array + { + return $this->commandService->importRemote($remoteUrl, $data, $createdBy, $createdByName); + } + + public function delete(int $id): bool + { + return $this->commandService->delete($id); + } + + public function previewResponse(int $id) + { + $asset = $this->queryService->detail($id); + + return $this->storageManager->previewResponse($asset); + } + + public function downloadResponse(int $id) + { + $asset = $this->queryService->detail($id); + + return $this->storageManager->downloadResponse($asset); + } +} diff --git a/app/service/file/StorageConfigService.php b/app/service/file/StorageConfigService.php new file mode 100644 index 0000000..79a92fa --- /dev/null +++ b/app/service/file/StorageConfigService.php @@ -0,0 +1,202 @@ +normalizeSelectableEngine((int) sys_config(FileConstant::CONFIG_DEFAULT_ENGINE, FileConstant::STORAGE_LOCAL)); + } + + public function localPublicBaseUrl(): string + { + $baseUrl = trim((string) sys_config(FileConstant::CONFIG_LOCAL_PUBLIC_BASE_URL, '')); + if ($baseUrl !== '') { + return rtrim($baseUrl, '/'); + } + + $siteUrl = trim((string) sys_config('site_url', '')); + if ($siteUrl !== '') { + return rtrim($siteUrl, '/'); + } + + return ''; + } + + public function localPublicDir(): string + { + $dir = trim((string) sys_config(FileConstant::CONFIG_LOCAL_PUBLIC_DIR, 'storage/uploads'), "/ \t\n\r\0\x0B"); + + return $dir !== '' ? $dir : 'storage/uploads'; + } + + public function localPrivateDir(): string + { + $dir = trim((string) sys_config(FileConstant::CONFIG_LOCAL_PRIVATE_DIR, 'storage/private'), "/ \t\n\r\0\x0B"); + + return $dir !== '' ? $dir : 'storage/private'; + } + + public function uploadMaxSizeBytes(): int + { + $mb = max(1, (int) sys_config(FileConstant::CONFIG_UPLOAD_MAX_SIZE_MB, 20)); + + return $mb * 1024 * 1024; + } + + public function remoteDownloadLimitBytes(): int + { + $mb = max(1, (int) sys_config(FileConstant::CONFIG_REMOTE_DOWNLOAD_LIMIT_MB, 10)); + + return $mb * 1024 * 1024; + } + + public function allowedExtensions(): array + { + $raw = trim((string) sys_config(FileConstant::CONFIG_ALLOWED_EXTENSIONS, implode(',', FileConstant::defaultAllowedExtensions()))); + if ($raw === '') { + return FileConstant::defaultAllowedExtensions(); + } + + $extensions = array_filter(array_map(static fn (string $value): string => strtolower(trim($value)), explode(',', $raw))); + + return array_values(array_unique($extensions)); + } + + public function ossConfig(): array + { + return [ + 'region' => trim((string) sys_config(FileConstant::CONFIG_OSS_REGION, '')), + 'endpoint' => trim((string) sys_config(FileConstant::CONFIG_OSS_ENDPOINT, '')), + 'bucket' => trim((string) sys_config(FileConstant::CONFIG_OSS_BUCKET, '')), + 'access_key_id' => trim((string) sys_config(FileConstant::CONFIG_OSS_ACCESS_KEY_ID, '')), + 'access_key_secret' => trim((string) sys_config(FileConstant::CONFIG_OSS_ACCESS_KEY_SECRET, '')), + 'public_domain' => trim((string) sys_config(FileConstant::CONFIG_OSS_PUBLIC_DOMAIN, '')), + ]; + } + + public function cosConfig(): array + { + return [ + 'region' => trim((string) sys_config(FileConstant::CONFIG_COS_REGION, '')), + 'bucket' => trim((string) sys_config(FileConstant::CONFIG_COS_BUCKET, '')), + 'secret_id' => trim((string) sys_config(FileConstant::CONFIG_COS_SECRET_ID, '')), + 'secret_key' => trim((string) sys_config(FileConstant::CONFIG_COS_SECRET_KEY, '')), + 'public_domain' => trim((string) sys_config(FileConstant::CONFIG_COS_PUBLIC_DOMAIN, '')), + ]; + } + + public function normalizeScene(int|string|null $scene = null, string $originalName = '', string $mimeType = ''): int + { + $scene = (int) $scene; + if ($scene === FileConstant::SCENE_IMAGE + || $scene === FileConstant::SCENE_CERTIFICATE + || $scene === FileConstant::SCENE_TEXT + || $scene === FileConstant::SCENE_OTHER + ) { + return $scene; + } + + $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); + if ($ext !== '') { + if (isset(FileConstant::imageExtensionMap()[$ext]) || str_starts_with(strtolower($mimeType), 'image/')) { + return FileConstant::SCENE_IMAGE; + } + + if (isset(FileConstant::certificateExtensionMap()[$ext])) { + return FileConstant::SCENE_CERTIFICATE; + } + + if (isset(FileConstant::textExtensionMap()[$ext]) || str_starts_with(strtolower($mimeType), 'text/')) { + return FileConstant::SCENE_TEXT; + } + } + + return FileConstant::SCENE_OTHER; + } + + public function normalizeVisibility(int|string|null $visibility = null, int $scene = FileConstant::SCENE_OTHER): int + { + $visibility = (int) $visibility; + if ($visibility === FileConstant::VISIBILITY_PUBLIC || $visibility === FileConstant::VISIBILITY_PRIVATE) { + return $visibility; + } + + return $scene === FileConstant::SCENE_IMAGE + ? FileConstant::VISIBILITY_PUBLIC + : FileConstant::VISIBILITY_PRIVATE; + } + + public function normalizeEngine(int|string|null $engine = null): int + { + $engine = (int) $engine; + + return $this->normalizeSelectableEngine($engine); + } + + public function sceneFolder(int $scene): string + { + return match ($scene) { + FileConstant::SCENE_IMAGE => 'image', + FileConstant::SCENE_CERTIFICATE => 'certificate', + FileConstant::SCENE_TEXT => 'text', + default => 'other', + }; + } + + public function buildObjectKey(int $scene, int $visibility, string $extension): string + { + $extension = strtolower(trim($extension, ". \t\n\r\0\x0B")); + $timestampPath = date('Y/m/d'); + $random = bin2hex(random_bytes(8)); + $name = date('YmdHis') . '_' . $random; + if ($extension !== '') { + $name .= '.' . $extension; + } + + $rootDir = $visibility === FileConstant::VISIBILITY_PUBLIC + ? $this->localPublicDir() + : $this->localPrivateDir(); + + return trim($rootDir . '/' . $this->sceneFolder($scene) . '/' . $timestampPath . '/' . $name, '/'); + } + + public function buildLocalAbsolutePath(int $visibility, string $objectKey): string + { + $root = $visibility === FileConstant::VISIBILITY_PUBLIC + ? public_path() + : runtime_path(); + $relativePath = trim(str_replace('\\', '/', $objectKey), '/'); + + return rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath); + } + + public function buildLocalPublicUrl(string $objectKey): string + { + $path = trim(str_replace('\\', '/', $objectKey), '/'); + $baseUrl = $this->localPublicBaseUrl(); + + if ($baseUrl !== '') { + return rtrim($baseUrl, '/') . '/' . $path; + } + + return '/' . $path; + } + + private function normalizeSelectableEngine(int $engine): int + { + return match ($engine) { + FileConstant::STORAGE_LOCAL, + FileConstant::STORAGE_ALIYUN_OSS, + FileConstant::STORAGE_TENCENT_COS => $engine, + default => FileConstant::STORAGE_LOCAL, + }; + } +} diff --git a/app/service/file/storage/AbstractStorageDriver.php b/app/service/file/storage/AbstractStorageDriver.php new file mode 100644 index 0000000..61a1199 --- /dev/null +++ b/app/service/file/storage/AbstractStorageDriver.php @@ -0,0 +1,113 @@ +assetValue($asset, 'object_key', '')); + $visibility = (int) $this->assetValue($asset, 'visibility', FileConstant::VISIBILITY_PRIVATE); + $candidate = ''; + + if ($objectKey !== '') { + $candidate = $this->storageConfigService->buildLocalAbsolutePath($visibility, $objectKey); + if ($candidate !== '' && is_file($candidate)) { + return $candidate; + } + } + + foreach (['url', 'public_url'] as $field) { + $url = trim((string) $this->assetValue($asset, $field, '')); + if ($url === '') { + continue; + } + + $parsedPath = (string) parse_url($url, PHP_URL_PATH); + if ($parsedPath === '') { + continue; + } + + $candidate = public_path() . DIRECTORY_SEPARATOR . ltrim($parsedPath, '/'); + if (is_file($candidate)) { + return $candidate; + } + } + + return $candidate; + } + + protected function bodyResponse(string $body, string $mimeType = 'application/octet-stream', int $status = 200, array $headers = []): Response + { + $responseHeaders = array_merge([ + 'Content-Type' => $mimeType !== '' ? $mimeType : 'application/octet-stream', + ], $headers); + + return response($body, $status, $responseHeaders); + } + + protected function downloadBodyResponse(string $body, string $downloadName, string $mimeType = 'application/octet-stream'): Response + { + $response = $this->bodyResponse($body, $mimeType, 200, [ + 'Content-Disposition' => 'attachment; filename="' . str_replace(['"', "\r", "\n", "\0"], '', $downloadName) . '"', + ]); + + return $response; + } + + protected function responseFromPath(string $path, string $downloadName = '', bool $attachment = false): Response + { + if ($attachment) { + return response()->download($path, $downloadName); + } + + return response()->file($path); + } + + protected function localPreviewResponse(array $asset): Response + { + $path = $this->resolveLocalAbsolutePath($asset); + if ($path === '' || !is_file($path)) { + return response('文件不存在', 404); + } + + return $this->responseFromPath($path); + } + + protected function localDownloadResponse(array $asset): Response + { + $path = $this->resolveLocalAbsolutePath($asset); + if ($path === '' || !is_file($path)) { + return response('文件不存在', 404); + } + + return $this->responseFromPath($path, (string) $this->assetValue($asset, 'original_name', basename($path)), true); + } + + protected function scenePrefix(int $scene): string + { + return match ($scene) { + FileConstant::SCENE_IMAGE => 'image', + FileConstant::SCENE_CERTIFICATE => 'certificate', + FileConstant::SCENE_TEXT => 'text', + default => 'other', + }; + } +} diff --git a/app/service/file/storage/CosStorageDriver.php b/app/service/file/storage/CosStorageDriver.php new file mode 100644 index 0000000..b9e537c --- /dev/null +++ b/app/service/file/storage/CosStorageDriver.php @@ -0,0 +1,188 @@ +storageConfigService->cosConfig(); + foreach (['region', 'bucket', 'secret_id', 'secret_key'] as $key) { + if (trim((string) ($config[$key] ?? '')) === '') { + throw new BusinessStateException('腾讯云 COS 存储配置未完整'); + } + } + + $client = $this->client($config); + $objectKey = (string) ($context['object_key'] ?? ''); + $client->putObject([ + 'Bucket' => (string) $config['bucket'], + 'Key' => $objectKey, + 'Body' => fopen($sourcePath, 'rb'), + ]); + + $publicUrl = $this->publicUrl([ + 'object_key' => $objectKey, + ]); + $visibility = (int) ($context['visibility'] ?? FileConstant::VISIBILITY_PRIVATE); + + return [ + 'storage_engine' => $this->engine(), + 'object_key' => $objectKey, + 'url' => $visibility === FileConstant::VISIBILITY_PUBLIC ? $publicUrl : '', + 'public_url' => $publicUrl, + ]; + } + + public function delete(array $asset): bool + { + $config = $this->storageConfigService->cosConfig(); + if (trim((string) ($config['bucket'] ?? '')) === '') { + return false; + } + + $objectKey = (string) ($asset['object_key'] ?? ''); + if ($objectKey === '') { + return true; + } + + $client = $this->client($config); + $client->deleteObject([ + 'Bucket' => (string) $config['bucket'], + 'Key' => $objectKey, + ]); + + return true; + } + + public function previewResponse(array $asset): Response + { + $url = $this->publicUrl($asset); + if ($url !== '') { + return redirect($url); + } + + return $this->responseFromObject($asset, false); + } + + public function downloadResponse(array $asset): Response + { + return $this->responseFromObject($asset, true); + } + + public function publicUrl(array $asset): string + { + $publicUrl = trim((string) ($asset['url'] ?? $asset['public_url'] ?? '')); + if ($publicUrl !== '') { + return $publicUrl; + } + + $config = $this->storageConfigService->cosConfig(); + $objectKey = (string) ($asset['object_key'] ?? ''); + if ($objectKey === '') { + return ''; + } + + $customDomain = trim((string) ($config['public_domain'] ?? '')); + if ($customDomain !== '') { + return rtrim($customDomain, '/') . '/' . ltrim($objectKey, '/'); + } + + $region = trim((string) ($config['region'] ?? '')); + $bucket = trim((string) ($config['bucket'] ?? '')); + if ($region === '' || $bucket === '') { + return ''; + } + + return 'https://' . $bucket . '.cos.' . $region . '.myqcloud.com/' . ltrim($objectKey, '/'); + } + + public function temporaryUrl(array $asset): string + { + $config = $this->storageConfigService->cosConfig(); + if (trim((string) ($config['bucket'] ?? '')) === '' || trim((string) ($config['region'] ?? '')) === '') { + return $this->publicUrl($asset); + } + + try { + $client = $this->client($config); + $objectKey = (string) ($asset['object_key'] ?? ''); + if ($objectKey === '') { + return ''; + } + + return $client->getObjectUrl( + (string) $config['bucket'], + $objectKey + ); + } catch (Throwable) { + return $this->publicUrl($asset); + } + } + + private function client(array $config): CosClient + { + return new CosClient([ + 'region' => (string) $config['region'], + 'credentials' => [ + 'secretId' => (string) $config['secret_id'], + 'secretKey' => (string) $config['secret_key'], + ], + ]); + } + + private function responseFromObject(array $asset, bool $attachment): Response + { + $config = $this->storageConfigService->cosConfig(); + $bucket = trim((string) ($config['bucket'] ?? '')); + $objectKey = (string) ($asset['object_key'] ?? ''); + if ($bucket === '' || $objectKey === '') { + return response('文件不存在', 404); + } + + try { + $client = $this->client($config); + $result = $client->getObject([ + 'Bucket' => $bucket, + 'Key' => $objectKey, + ]); + + $body = ''; + if (is_string($result)) { + $body = $result; + } elseif (is_object($result) && method_exists($result, '__toString')) { + $body = (string) $result; + } elseif (is_array($result)) { + $body = (string) ($result['Body'] ?? $result['body'] ?? ''); + } + + $mimeType = (string) ($asset['mime_type'] ?? 'application/octet-stream'); + + if ($attachment) { + return $this->downloadBodyResponse($body, (string) ($asset['original_name'] ?? basename($objectKey)), $mimeType); + } + + return $this->bodyResponse($body, $mimeType); + } catch (Throwable) { + return response('文件不存在', 404); + } + } +} diff --git a/app/service/file/storage/LocalStorageDriver.php b/app/service/file/storage/LocalStorageDriver.php new file mode 100644 index 0000000..8cc8530 --- /dev/null +++ b/app/service/file/storage/LocalStorageDriver.php @@ -0,0 +1,121 @@ +storageConfigService->buildLocalAbsolutePath($visibility, $objectKey); + $publicUrl = (string) ($context['public_url'] ?? ''); + + if ($objectKey === '' || $absolutePath === '') { + throw new BusinessStateException('文件存储路径无效'); + } + + $this->ensureDirectory(dirname($absolutePath)); + + if (@rename($sourcePath, $absolutePath) === false) { + if (!@copy($sourcePath, $absolutePath)) { + throw new BusinessStateException('本地文件保存失败'); + } + + @unlink($sourcePath); + } + + @chmod($absolutePath, 0666 & ~umask()); + + return [ + 'storage_engine' => $this->engine(), + 'object_key' => $objectKey, + 'url' => $visibility === FileConstant::VISIBILITY_PUBLIC ? $publicUrl : '', + 'public_url' => $publicUrl, + ]; + } + + public function delete(array $asset): bool + { + $path = $this->resolveLocalAbsolutePath($asset); + if ($path === '' || !is_file($path)) { + return true; + } + + return @unlink($path); + } + + public function previewResponse(array $asset): Response + { + return $this->localPreviewResponse($asset); + } + + public function downloadResponse(array $asset): Response + { + return $this->localDownloadResponse($asset); + } + + public function publicUrl(array $asset): string + { + $url = trim((string) ($asset['url'] ?? $asset['public_url'] ?? '')); + if ($url !== '') { + return $url; + } + + $visibility = (int) ($asset['visibility'] ?? FileConstant::VISIBILITY_PRIVATE); + if ($visibility !== FileConstant::VISIBILITY_PUBLIC) { + return ''; + } + + $objectKey = trim((string) ($asset['object_key'] ?? '')); + if ($objectKey === '') { + return ''; + } + + return $this->storageConfigService->buildLocalPublicUrl($objectKey); + } + + public function temporaryUrl(array $asset): string + { + $url = trim((string) ($asset['url'] ?? $asset['public_url'] ?? '')); + if ($url !== '') { + return $url; + } + + $visibility = (int) ($asset['visibility'] ?? FileConstant::VISIBILITY_PRIVATE); + if ($visibility === FileConstant::VISIBILITY_PUBLIC) { + return $this->publicUrl($asset); + } + + $id = (int) ($asset['id'] ?? 0); + + return $id > 0 ? '/adminapi/file-asset/' . $id . '/preview' : ''; + } + + private function ensureDirectory(string $directory): void + { + if (is_dir($directory)) { + return; + } + + if (!@mkdir($directory, 0777, true) && !is_dir($directory)) { + throw new BusinessStateException('文件目录创建失败'); + } + } +} diff --git a/app/service/file/storage/OssStorageDriver.php b/app/service/file/storage/OssStorageDriver.php new file mode 100644 index 0000000..caa1baf --- /dev/null +++ b/app/service/file/storage/OssStorageDriver.php @@ -0,0 +1,196 @@ +storageConfigService->ossConfig(); + foreach (['region', 'bucket', 'access_key_id', 'access_key_secret'] as $key) { + if (trim((string) ($config[$key] ?? '')) === '') { + throw new BusinessStateException('阿里云 OSS 存储配置未完整'); + } + } + + $client = $this->client($config); + $objectKey = (string) ($context['object_key'] ?? ''); + $request = new Oss\Models\PutObjectRequest( + bucket: (string) $config['bucket'], + key: $objectKey + ); + $request->body = Oss\Utils::streamFor(fopen($sourcePath, 'rb')); + + $client->putObject($request); + + $publicUrl = $this->publicUrl([ + 'object_key' => $objectKey, + ]); + $visibility = (int) ($context['visibility'] ?? FileConstant::VISIBILITY_PRIVATE); + + return [ + 'storage_engine' => $this->engine(), + 'object_key' => $objectKey, + 'url' => $visibility === FileConstant::VISIBILITY_PUBLIC ? $publicUrl : '', + 'public_url' => $publicUrl, + ]; + } + + public function delete(array $asset): bool + { + $config = $this->storageConfigService->ossConfig(); + if (trim((string) ($config['bucket'] ?? '')) === '') { + return false; + } + + $objectKey = (string) ($asset['object_key'] ?? ''); + if ($objectKey === '') { + return true; + } + + $client = $this->client($config); + $request = new Oss\Models\DeleteObjectRequest( + bucket: (string) $config['bucket'], + key: $objectKey + ); + $client->deleteObject($request); + + return true; + } + + public function previewResponse(array $asset): Response + { + $url = $this->publicUrl($asset); + if ($url !== '') { + return redirect($url); + } + + return $this->responseFromObject($asset, false); + } + + public function downloadResponse(array $asset): Response + { + return $this->responseFromObject($asset, true); + } + + public function publicUrl(array $asset): string + { + $publicUrl = trim((string) ($asset['url'] ?? $asset['public_url'] ?? '')); + if ($publicUrl !== '') { + return $publicUrl; + } + + $config = $this->storageConfigService->ossConfig(); + $objectKey = (string) ($asset['object_key'] ?? ''); + if ($objectKey === '') { + return ''; + } + + $customDomain = trim((string) ($config['public_domain'] ?? '')); + if ($customDomain !== '') { + return rtrim($customDomain, '/') . '/' . ltrim($objectKey, '/'); + } + + $endpoint = trim((string) ($config['endpoint'] ?? '')); + $bucket = trim((string) ($config['bucket'] ?? '')); + if ($endpoint === '' || $bucket === '') { + return ''; + } + + $endpoint = preg_replace('#^https?://#i', '', $endpoint) ?: $endpoint; + + return 'https://' . $bucket . '.' . ltrim($endpoint, '/') . '/' . ltrim($objectKey, '/'); + } + + public function temporaryUrl(array $asset): string + { + $config = $this->storageConfigService->ossConfig(); + if (trim((string) ($config['bucket'] ?? '')) === '' || trim((string) ($config['region'] ?? '')) === '') { + return $this->publicUrl($asset); + } + + try { + $client = $this->client($config); + $objectKey = (string) ($asset['object_key'] ?? ''); + if ($objectKey === '') { + return ''; + } + + $request = new Oss\Models\GetObjectRequest( + bucket: (string) $config['bucket'], + key: $objectKey + ); + $result = $client->presign($request); + + return (string) ($result->url ?? ''); + } catch (Throwable) { + return $this->publicUrl($asset); + } + } + + private function client(array $config): Oss\Client + { + $provider = new Oss\Credentials\StaticCredentialsProvider( + accessKeyId: (string) $config['access_key_id'], + accessKeySecret: (string) $config['access_key_secret'] + ); + + $cfg = Oss\Config::loadDefault(); + $cfg->setCredentialsProvider(credentialsProvider: $provider); + $cfg->setRegion(region: (string) $config['region']); + + $endpoint = trim((string) ($config['endpoint'] ?? '')); + if ($endpoint !== '') { + $cfg->setEndpoint(endpoint: $endpoint); + } + + return new Oss\Client($cfg); + } + + private function responseFromObject(array $asset, bool $attachment): Response + { + $config = $this->storageConfigService->ossConfig(); + $bucket = trim((string) ($config['bucket'] ?? '')); + $objectKey = (string) ($asset['object_key'] ?? ''); + if ($bucket === '' || $objectKey === '') { + return response('文件不存在', 404); + } + + try { + $client = $this->client($config); + $request = new Oss\Models\GetObjectRequest( + bucket: $bucket, + key: $objectKey + ); + $result = $client->getObject($request); + $body = (string) $result->body->getContents(); + $mimeType = (string) ($asset['mime_type'] ?? 'application/octet-stream'); + + if ($attachment) { + return $this->downloadBodyResponse($body, (string) ($asset['original_name'] ?? basename($objectKey)), $mimeType); + } + + return $this->bodyResponse($body, $mimeType); + } catch (Throwable) { + return response('文件不存在', 404); + } + } +} diff --git a/app/service/file/storage/RemoteUrlStorageDriver.php b/app/service/file/storage/RemoteUrlStorageDriver.php new file mode 100644 index 0000000..af0aef5 --- /dev/null +++ b/app/service/file/storage/RemoteUrlStorageDriver.php @@ -0,0 +1,53 @@ +previewResponse($asset); + } + + public function publicUrl(array $asset): string + { + return (string) ($asset['source_url'] ?? $asset['url'] ?? ''); + } + + public function temporaryUrl(array $asset): string + { + return (string) ($asset['source_url'] ?? $asset['url'] ?? ''); + } +} diff --git a/app/service/file/storage/StorageDriverInterface.php b/app/service/file/storage/StorageDriverInterface.php new file mode 100644 index 0000000..6cdbf28 --- /dev/null +++ b/app/service/file/storage/StorageDriverInterface.php @@ -0,0 +1,25 @@ +guessMimeType($sourcePath, $originalName); + $scene = $this->storageConfigService->normalizeScene($scene, $originalName, $mimeType); + $visibility = $this->storageConfigService->normalizeVisibility($visibility, $scene); + $engine = $this->storageConfigService->normalizeEngine($engine ?? $this->storageConfigService->defaultEngine()); + $ext = strtolower(trim(pathinfo($originalName, PATHINFO_EXTENSION))); + if ($ext === '') { + $ext = strtolower((string) pathinfo($sourcePath, PATHINFO_EXTENSION)); + } + + $objectKey = $this->storageConfigService->buildObjectKey($scene, $visibility, $ext); + $publicUrl = $this->buildPublicUrlByEngine($engine, $visibility, $objectKey); + + return [ + 'scene' => $scene, + 'visibility' => $visibility, + 'storage_engine' => $engine, + 'source_type' => $sourceType === 'remote_url' ? FileConstant::SOURCE_REMOTE_URL : FileConstant::SOURCE_UPLOAD, + 'source_url' => (string) ($sourceUrl ?? ''), + 'original_name' => $originalName, + 'file_name' => basename($objectKey), + 'file_ext' => $ext, + 'mime_type' => $mimeType, + 'size' => is_file($sourcePath) ? (int) filesize($sourcePath) : 0, + 'md5' => is_file($sourcePath) ? (string) md5_file($sourcePath) : '', + 'object_key' => $objectKey, + 'public_url' => $publicUrl, + ]; + } + + public function storeFromPath( + string $sourcePath, + string $originalName, + ?int $scene = null, + ?int $visibility = null, + ?int $engine = null, + ?string $sourceUrl = null, + string $sourceType = 'upload' + ): array { + $context = $this->buildContext($sourcePath, $originalName, $scene, $visibility, $engine, $sourceUrl, $sourceType); + $driver = $this->resolveDriver((int) $context['storage_engine']); + + return array_merge($context, $driver->storeFromPath($sourcePath, $context)); + } + + public function delete(array $asset): bool + { + return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL)) + ->delete($asset); + } + + public function previewResponse(array $asset): Response + { + return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL)) + ->previewResponse($asset); + } + + public function downloadResponse(array $asset): Response + { + return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL)) + ->downloadResponse($asset); + } + + public function publicUrl(array $asset): string + { + return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL)) + ->publicUrl($asset); + } + + public function temporaryUrl(array $asset): string + { + return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL)) + ->temporaryUrl($asset); + } + + public function resolveDriver(int $engine): StorageDriverInterface + { + return match ($engine) { + FileConstant::STORAGE_LOCAL => $this->localStorageDriver, + FileConstant::STORAGE_ALIYUN_OSS => $this->ossStorageDriver, + FileConstant::STORAGE_TENCENT_COS => $this->cosStorageDriver, + FileConstant::STORAGE_REMOTE_URL => $this->remoteUrlStorageDriver, + default => $this->localStorageDriver, + }; + } + + private function buildPublicUrlByEngine(int $engine, int $visibility, string $objectKey): string + { + if ($engine === FileConstant::STORAGE_LOCAL && $visibility === FileConstant::VISIBILITY_PUBLIC) { + return $this->storageConfigService->buildLocalPublicUrl($objectKey); + } + + return ''; + } + + private function guessMimeType(string $sourcePath, string $originalName): string + { + $mimeType = ''; + if (is_file($sourcePath) && function_exists('mime_content_type')) { + $detected = @mime_content_type($sourcePath); + if (is_string($detected)) { + $mimeType = trim($detected); + } + } + + if ($mimeType !== '') { + return $mimeType; + } + + $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); + return match ($ext) { + 'jpg', 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'svg' => 'image/svg+xml', + 'bmp' => 'image/bmp', + 'txt', 'log', 'md', 'ini', 'conf', 'yml', 'yaml' => 'text/plain', + 'json' => 'application/json', + 'xml' => 'application/xml', + 'csv' => 'text/csv', + 'pem' => 'application/x-pem-file', + 'crt', 'cer' => 'application/x-x509-ca-cert', + 'key' => 'application/octet-stream', + default => 'application/octet-stream', + }; + } +} diff --git a/app/service/merchant/MerchantCommandService.php b/app/service/merchant/MerchantCommandService.php new file mode 100644 index 0000000..50cc1d5 --- /dev/null +++ b/app/service/merchant/MerchantCommandService.php @@ -0,0 +1,269 @@ +transaction(function () use ($data) { + $merchantName = trim((string) ($data['merchant_name'] ?? '')); + $contactName = trim((string) ($data['contact_name'] ?? '')); + $contactPhone = trim((string) ($data['contact_phone'] ?? '')); + $groupId = (int) ($data['group_id'] ?? 0); + if ($merchantName === '') { + throw new ValidationException('商户名称不能为空'); + } + if ($groupId <= 0) { + throw new ValidationException('请选择商户分组'); + } + if ($contactName === '') { + throw new ValidationException('联系人不能为空'); + } + if ($contactPhone === '') { + throw new ValidationException('联系电话不能为空'); + } + if ($groupId > 0) { + $this->ensureMerchantGroupEnabled($groupId); + } + + $merchantNo = $this->generateMerchantNo(); + $plainPassword = $this->generateTemporaryPassword(); + + $merchant = $this->merchantRepository->create([ + 'merchant_no' => $merchantNo, + 'password_hash' => password_hash($plainPassword, PASSWORD_DEFAULT), + 'merchant_name' => $merchantName, + 'merchant_short_name' => trim((string) ($data['merchant_short_name'] ?? '')), + 'merchant_type' => (int) ($data['merchant_type'] ?? 0), + 'group_id' => $groupId, + 'risk_level' => (int) ($data['risk_level'] ?? 0), + 'contact_name' => $contactName, + 'contact_phone' => $contactPhone, + 'contact_email' => trim((string) ($data['contact_email'] ?? '')), + 'settlement_account_name' => trim((string) ($data['settlement_account_name'] ?? '')), + 'settlement_account_no' => trim((string) ($data['settlement_account_no'] ?? '')), + 'settlement_bank_name' => trim((string) ($data['settlement_bank_name'] ?? '')), + 'settlement_bank_branch' => trim((string) ($data['settlement_bank_branch'] ?? '')), + 'status' => (int) ($data['status'] ?? CommonConstant::STATUS_ENABLED), + 'password_updated_at' => $this->now(), + 'remark' => trim((string) ($data['remark'] ?? '')), + ]); + + $merchant->plain_password = $plainPassword; + + $this->merchantAccountService->ensureAccountInCurrentTransaction((int) $merchant->id); + + return $merchant; + }); + } + + public function update(int $merchantId, array $data): ?Merchant + { + $merchant = $this->merchantRepository->find($merchantId); + if (!$merchant) { + return null; + } + + $groupId = array_key_exists('group_id', $data) ? (int) $data['group_id'] : (int) $merchant->group_id; + if ($groupId > 0) { + $this->ensureMerchantGroupEnabled($groupId); + } + + $payload = [ + 'merchant_name' => (string) ($data['merchant_name'] ?? $merchant->merchant_name), + 'merchant_short_name' => (string) ($data['merchant_short_name'] ?? $merchant->merchant_short_name), + 'merchant_type' => (int) ($data['merchant_type'] ?? $merchant->merchant_type), + 'group_id' => $groupId, + 'risk_level' => (int) ($data['risk_level'] ?? $merchant->risk_level), + 'contact_name' => (string) ($data['contact_name'] ?? $merchant->contact_name), + 'contact_phone' => (string) ($data['contact_phone'] ?? $merchant->contact_phone), + 'contact_email' => (string) ($data['contact_email'] ?? $merchant->contact_email), + 'settlement_account_name' => (string) ($data['settlement_account_name'] ?? $merchant->settlement_account_name), + 'settlement_account_no' => (string) ($data['settlement_account_no'] ?? $merchant->settlement_account_no), + 'settlement_bank_name' => (string) ($data['settlement_bank_name'] ?? $merchant->settlement_bank_name), + 'settlement_bank_branch' => (string) ($data['settlement_bank_branch'] ?? $merchant->settlement_bank_branch), + 'status' => (int) ($data['status'] ?? $merchant->status), + 'remark' => (string) ($data['remark'] ?? $merchant->remark), + ]; + + if (!$this->merchantRepository->updateById($merchantId, $payload)) { + return null; + } + + return $this->merchantRepository->find($merchantId); + } + + public function delete(int $merchantId): bool + { + $merchant = $this->merchantRepository->find($merchantId); + if (!$merchant) { + throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]); + } + + $dependencies = [ + ['count' => $this->paymentChannelRepository->countByMerchantId($merchantId), 'message' => '已配置支付通道'], + ['count' => $this->bizOrderRepository->countByMerchantId($merchantId), 'message' => '已存在支付订单'], + ['count' => $this->refundOrderRepository->countByMerchantId($merchantId), 'message' => '已存在退款订单'], + ['count' => $this->settlementOrderRepository->countByMerchantId($merchantId), 'message' => '已存在清算记录'], + ['count' => $this->merchantAccountRepository->countByMerchantId($merchantId), 'message' => '已存在资金账户'], + ['count' => $this->merchantApiCredentialRepository->countByMerchantId($merchantId), 'message' => '已开通接口凭证'], + ]; + + foreach ($dependencies as $dependency) { + if ((int) $dependency['count'] > 0) { + throw new BusinessStateException("当前商户{$dependency['message']},请先清理关联数据后再删除", [ + 'merchant_id' => $merchantId, + 'message' => $dependency['message'], + ]); + } + } + + return $this->merchantRepository->deleteById($merchantId); + } + + public function resetPassword(int $merchantId, string $password): Merchant + { + $merchant = $this->merchantRepository->find($merchantId); + if (!$merchant) { + throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]); + } + + $this->merchantRepository->updateById($merchantId, [ + 'password_hash' => password_hash($password, PASSWORD_DEFAULT), + 'password_updated_at' => $this->now(), + ]); + + return $this->merchantRepository->find($merchantId); + } + + public function verifyPassword(Merchant $merchant, string $password): bool + { + return $password !== '' && password_verify($password, (string) $merchant->password_hash); + } + + public function touchLoginMeta(int $merchantId, string $ip = ''): void + { + $this->merchantRepository->updateById($merchantId, [ + 'last_login_at' => $this->now(), + 'last_login_ip' => trim($ip), + ]); + } + + public function issueCredential(int $merchantId): array + { + $merchant = $this->merchantQueryService->findById($merchantId); + if (!$merchant) { + throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]); + } + + $credentialValue = $this->merchantApiCredentialService->issueCredential($merchantId); + $credential = $this->merchantApiCredentialService->findByMerchantId($merchantId); + + return [ + 'merchant' => $merchant, + 'credential_value' => $credentialValue, + 'credential' => $credential, + ]; + } + + public function findEnabledMerchantByNo(string $merchantNo): Merchant + { + $merchant = $this->merchantRepository->findByMerchantNo($merchantNo); + + if (!$merchant) { + throw new ResourceNotFoundException('商户不存在', ['merchant_no' => $merchantNo]); + } + + if ((int) $merchant->status !== CommonConstant::STATUS_ENABLED) { + throw new BusinessStateException('商户已禁用', ['merchant_no' => $merchantNo]); + } + + return $merchant; + } + + public function ensureMerchantEnabled(int $merchantId): Merchant + { + $merchant = $this->merchantRepository->find($merchantId); + + if (!$merchant) { + throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]); + } + + if ((int) $merchant->status !== CommonConstant::STATUS_ENABLED) { + throw new BusinessStateException('商户已禁用', ['merchant_id' => $merchantId]); + } + + return $merchant; + } + + public function ensureMerchantGroupEnabled(int $groupId): MerchantGroup + { + $group = $this->merchantGroupRepository->find($groupId); + + if (!$group) { + throw new ResourceNotFoundException('商户分组不存在', ['merchant_group_id' => $groupId]); + } + + if ((int) $group->status !== CommonConstant::STATUS_ENABLED) { + throw new BusinessStateException('商户分组已禁用', ['merchant_group_id' => $groupId]); + } + + return $group; + } + + private function generateMerchantNo(): string + { + do { + $merchantNo = $this->generateNo('M'); + } while ($this->merchantRepository->findByMerchantNo($merchantNo) !== null); + + return $merchantNo; + } + + /** + * 生成商户初始临时密码。 + */ + private function generateTemporaryPassword(): string + { + return bin2hex(random_bytes(8)); + } +} diff --git a/app/service/merchant/MerchantOverviewQueryService.php b/app/service/merchant/MerchantOverviewQueryService.php new file mode 100644 index 0000000..65153d2 --- /dev/null +++ b/app/service/merchant/MerchantOverviewQueryService.php @@ -0,0 +1,130 @@ +merchantQueryService->findById($merchantId); + if (!$merchant) { + throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]); + } + + $account = $this->merchantAccountRepository->findByMerchantId($merchantId); + $credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); + $channelSummary = $this->paymentChannelRepository->summaryByMerchantId($merchantId); + + $bindSummary = []; + if ((int) $merchant->group_id > 0) { + $bindSummary = $this->paymentPollGroupBindRepository + ->listSummaryByMerchantGroupId((int) $merchant->group_id) + ->map(function ($row) { + $row->status_text = (int) $row->status === CommonConstant::STATUS_ENABLED ? '启用' : '禁用'; + $routeModeMap = RouteConstant::routeModeMap(); + $row->route_mode_text = (string) ($routeModeMap[(int) ($row->route_mode ?? -1)] ?? '未知'); + + return $row; + }) + ->all(); + } + + $recentPayOrders = $this->payOrderRepository + ->recentByMerchantId($merchantId, 5) + ->map(function ($row) { + $row->pay_amount_text = $this->formatAmount((int) $row->pay_amount); + $row->status_text = match ((int) $row->status) { + 0 => '待创建', + 1 => '支付中', + 2 => '成功', + 3 => '失败', + 4 => '关闭', + 5 => '超时', + default => '未知', + }; + + return $row; + }) + ->all(); + + $recentSettlements = $this->settlementOrderRepository + ->recentByMerchantId($merchantId, 5) + ->map(function ($row) { + $row->net_amount_text = $this->formatAmount((int) $row->net_amount); + $row->status_text = match ((int) $row->status) { + 0 => '待处理', + 1 => '处理中', + 2 => '成功', + 3 => '失败', + 4 => '已冲正', + default => '未知', + }; + + return $row; + }) + ->all(); + + return [ + 'merchant' => $merchant, + 'access' => [ + 'login_identity' => (string) $merchant->merchant_no, + 'login_mode_text' => '商户号 + 密码', + 'has_credential' => $credential !== null, + 'credential_enabled' => (int) ($credential->status ?? 0) === CommonConstant::STATUS_ENABLED, + 'credential_status_text' => (int) ($credential->status ?? 0) === CommonConstant::STATUS_ENABLED ? '已开通' : '未开通', + 'sign_type_text' => $this->textFromMap((int) ($credential->sign_type ?? 0), \app\common\constant\AuthConstant::signTypeMap()), + 'credential_last_used_at' => $this->formatDateTime($credential->last_used_at ?? null), + ], + 'route' => [ + 'merchant_group_id' => (int) $merchant->group_id, + 'merchant_group_name' => (string) ($merchant->group_name ?? '未分组'), + 'bind_count' => count($bindSummary), + 'binds' => $bindSummary, + ], + 'funds' => [ + 'has_account' => $account !== null, + 'available_balance' => (int) ($account->available_balance ?? 0), + 'available_balance_text' => $this->formatAmount((int) ($account->available_balance ?? 0)), + 'frozen_balance' => (int) ($account->frozen_balance ?? 0), + 'frozen_balance_text' => $this->formatAmount((int) ($account->frozen_balance ?? 0)), + ], + 'channels' => [ + 'total_count' => (int) ($channelSummary->total_count ?? 0), + 'enabled_count' => (int) ($channelSummary->enabled_count ?? 0), + 'self_count' => (int) ($channelSummary->self_count ?? 0), + ], + 'recent_pay_orders' => $recentPayOrders, + 'recent_settlements' => $recentSettlements, + ]; + } +} diff --git a/app/service/merchant/MerchantQueryService.php b/app/service/merchant/MerchantQueryService.php new file mode 100644 index 0000000..25b7f0c --- /dev/null +++ b/app/service/merchant/MerchantQueryService.php @@ -0,0 +1,235 @@ +merchantRepository->query() + ->from('ma_merchant as m') + ->leftJoin('ma_merchant_group as g', 'm.group_id', '=', 'g.id') + ->select([ + 'm.id', + 'm.merchant_no', + 'm.merchant_name', + 'm.merchant_short_name', + 'm.merchant_type', + 'm.group_id', + 'm.risk_level', + 'm.contact_name', + 'm.contact_phone', + 'm.contact_email', + 'm.settlement_account_name', + 'm.settlement_account_no', + 'm.settlement_bank_name', + 'm.settlement_bank_branch', + 'm.status', + 'm.last_login_at', + 'm.last_login_ip', + 'm.password_updated_at', + 'm.remark', + 'm.created_at', + 'm.updated_at', + ]) + ->selectRaw("COALESCE(g.group_name, '未分组') AS group_name") + ->selectRaw("CASE m.merchant_type WHEN 0 THEN '个人' WHEN 1 THEN '企业' ELSE '其他' END AS merchant_type_text") + ->selectRaw("CASE m.risk_level WHEN 0 THEN '低' WHEN 1 THEN '中' WHEN 2 THEN '高' ELSE '未知' END AS risk_level_text") + ->selectRaw("CASE m.status WHEN 0 THEN '禁用' WHEN 1 THEN '启用' ELSE '未知' END AS status_text"); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('m.merchant_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%') + ->orWhere('m.contact_name', 'like', '%' . $keyword . '%') + ->orWhere('m.contact_phone', 'like', '%' . $keyword . '%') + ->orWhere('g.group_name', 'like', '%' . $keyword . '%'); + }); + } + + $groupId = trim((string) ($filters['group_id'] ?? '')); + if ($groupId !== '') { + $query->where('m.group_id', (int) $groupId); + } + + $status = trim((string) ($filters['status'] ?? '')); + if ($status !== '') { + $query->where('m.status', (int) $status); + } + + $merchantType = trim((string) ($filters['merchant_type'] ?? '')); + if ($merchantType !== '') { + $query->where('m.merchant_type', (int) $merchantType); + } + + $riskLevel = trim((string) ($filters['risk_level'] ?? '')); + if ($riskLevel !== '') { + $query->where('m.risk_level', (int) $riskLevel); + } + + return $query + ->orderByDesc('m.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + } + + public function paginateWithGroupOptions(array $filters = [], int $page = 1, int $pageSize = 10): array + { + $paginator = $this->paginate($filters, $page, $pageSize); + + return [ + 'list' => $paginator->items(), + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'size' => $paginator->perPage(), + 'groups' => $this->enabledGroupOptions(), + ]; + } + + public function enabledOptions(): array + { + return $this->merchantRepository->enabledList(['id', 'merchant_no', 'merchant_name']) + ->map(function (Merchant $merchant): array { + return [ + 'label' => sprintf('%s(%s)', (string) $merchant->merchant_name, (string) $merchant->merchant_no), + 'value' => (int) $merchant->id, + ]; + }) + ->values() + ->all(); + } + + public function enabledGroupOptions(): array + { + return $this->merchantGroupRepository->enabledList(['id', 'group_name']) + ->map(static function ($group): array { + return [ + 'label' => (string) $group->group_name, + 'value' => (int) $group->id, + ]; + }) + ->values() + ->all(); + } + + public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array + { + $query = $this->merchantRepository->query() + ->from('ma_merchant as m') + ->where('m.status', CommonConstant::STATUS_ENABLED) + ->select([ + 'm.id', + 'm.merchant_no', + 'm.merchant_name', + 'm.merchant_short_name', + ]); + + $ids = $this->normalizeIds($filters['ids'] ?? []); + $keyword = trim((string) ($filters['keyword'] ?? '')); + + if (!empty($ids)) { + $query->whereIn('m.id', $ids); + } elseif ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('m.merchant_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%'); + }); + } + + $paginator = $query + ->orderByDesc('m.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + return [ + 'list' => collect($paginator->items()) + ->map(function ($merchant): array { + return [ + 'label' => sprintf('%s(%s)', (string) $merchant->merchant_name, (string) $merchant->merchant_no), + 'value' => (int) $merchant->id, + 'merchant_no' => (string) $merchant->merchant_no, + 'merchant_name' => (string) $merchant->merchant_name, + 'merchant_short_name' => (string) ($merchant->merchant_short_name ?? ''), + ]; + }) + ->values() + ->all(), + 'total' => (int) $paginator->total(), + 'page' => (int) $paginator->currentPage(), + 'size' => (int) $paginator->perPage(), + ]; + } + + public function findById(int $merchantId): ?object + { + return $this->merchantRepository->query() + ->from('ma_merchant as m') + ->leftJoin('ma_merchant_group as g', 'm.group_id', '=', 'g.id') + ->select([ + 'm.id', + 'm.merchant_no', + 'm.merchant_name', + 'm.merchant_short_name', + 'm.merchant_type', + 'm.group_id', + 'm.risk_level', + 'm.contact_name', + 'm.contact_phone', + 'm.contact_email', + 'm.settlement_account_name', + 'm.settlement_account_no', + 'm.settlement_bank_name', + 'm.settlement_bank_branch', + 'm.status', + 'm.last_login_at', + 'm.last_login_ip', + 'm.password_updated_at', + 'm.remark', + 'm.created_at', + 'm.updated_at', + ]) + ->selectRaw("COALESCE(g.group_name, '未分组') AS group_name") + ->selectRaw("CASE m.merchant_type WHEN 0 THEN '个人' WHEN 1 THEN '企业' ELSE '其他' END AS merchant_type_text") + ->selectRaw("CASE m.risk_level WHEN 0 THEN '低' WHEN 1 THEN '中' WHEN 2 THEN '高' ELSE '未知' END AS risk_level_text") + ->selectRaw("CASE m.status WHEN 0 THEN '禁用' WHEN 1 THEN '启用' ELSE '未知' END AS status_text") + ->where('m.id', $merchantId) + ->first(); + } + + public function findPolicy(int $merchantId): ?MerchantPolicy + { + return $this->merchantPolicyRepository->findByMerchantId($merchantId); + } + + private function normalizeIds(array|string|int $ids): array + { + if (is_string($ids)) { + $ids = array_filter(array_map('trim', explode(',', $ids))); + } elseif (!is_array($ids)) { + $ids = [$ids]; + } + + return array_values(array_filter(array_map(static fn ($id) => (int) $id, $ids), static fn ($id) => $id > 0)); + } +} diff --git a/app/service/merchant/MerchantService.php b/app/service/merchant/MerchantService.php new file mode 100644 index 0000000..f70c6c2 --- /dev/null +++ b/app/service/merchant/MerchantService.php @@ -0,0 +1,129 @@ +queryService->paginate($filters, $page, $pageSize); + } + + public function paginateWithGroupOptions(array $filters = [], int $page = 1, int $pageSize = 10): array + { + return $this->queryService->paginateWithGroupOptions($filters, $page, $pageSize); + } + + public function enabledOptions(): array + { + return $this->queryService->enabledOptions(); + } + + public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array + { + return $this->queryService->searchOptions($filters, $page, $pageSize); + } + + public function findById(int $merchantId): ?object + { + return $this->queryService->findById($merchantId); + } + + public function create(array $data): Merchant + { + return $this->commandService->create($data); + } + + public function createWithDetail(array $data): ?object + { + $merchant = $this->create($data); + $detail = $this->findById((int) $merchant->id); + if ($detail && isset($merchant->plain_password)) { + $detail->plain_password = (string) $merchant->plain_password; + } + + return $detail ?? $merchant; + } + + public function update(int $merchantId, array $data): ?Merchant + { + return $this->commandService->update($merchantId, $data); + } + + public function updateWithDetail(int $merchantId, array $data): ?object + { + $merchant = $this->update($merchantId, $data); + if (!$merchant) { + return null; + } + + return $this->findById($merchantId); + } + + public function delete(int $merchantId): bool + { + return $this->commandService->delete($merchantId); + } + + public function resetPassword(int $merchantId, string $password): Merchant + { + return $this->commandService->resetPassword($merchantId, $password); + } + + public function verifyPassword(Merchant $merchant, string $password): bool + { + return $this->commandService->verifyPassword($merchant, $password); + } + + public function touchLoginMeta(int $merchantId, string $ip = ''): void + { + $this->commandService->touchLoginMeta($merchantId, $ip); + } + + public function issueCredential(int $merchantId): array + { + return $this->commandService->issueCredential($merchantId); + } + + public function overview(int $merchantId): array + { + return $this->overviewQueryService->overview($merchantId); + } + + public function findEnabledMerchantByNo(string $merchantNo): Merchant + { + return $this->commandService->findEnabledMerchantByNo($merchantNo); + } + + public function ensureMerchantEnabled(int $merchantId): Merchant + { + return $this->commandService->ensureMerchantEnabled($merchantId); + } + + public function ensureMerchantGroupEnabled(int $groupId): MerchantGroup + { + return $this->commandService->ensureMerchantGroupEnabled($groupId); + } + + public function findPolicy(int $merchantId): ?MerchantPolicy + { + return $this->queryService->findPolicy($merchantId); + } +} diff --git a/app/service/merchant/auth/MerchantAuthService.php b/app/service/merchant/auth/MerchantAuthService.php new file mode 100644 index 0000000..9efe5d4 --- /dev/null +++ b/app/service/merchant/auth/MerchantAuthService.php @@ -0,0 +1,181 @@ +merchantPortalSupportService->merchantSummary($merchantId); + $credential = $merchantId > 0 ? $this->merchantApiCredentialRepository->findByMerchantId($merchantId) : null; + + $isCredentialEnabled = (int) ($credential->status ?? 0) === 1; + $user = [ + 'id' => $merchantId, + 'deptId' => (string) ($merchant['merchant_group_id'] ?? 0), + 'deptName' => (string) ($merchant['merchant_group_name'] ?? '未分组'), + 'userName' => (string) ($merchant['merchant_no'] !== '' ? $merchant['merchant_no'] : trim($merchantNo)), + 'nickName' => (string) ($merchant['merchant_name'] ?? '商户账号'), + 'email' => (string) ($merchant['contact_email'] ?? ''), + 'phone' => (string) ($merchant['contact_phone'] ?? ''), + 'sex' => 2, + 'avatar' => '', + 'status' => (int) ($merchant['status'] ?? 1), + 'description' => '商户主体账号(商户号 + 密码)', + 'roles' => [ + [ + 'code' => 'common', + 'name' => '普通用户', + 'admin' => false, + 'disabled' => false, + ], + ], + 'loginIp' => (string) ($merchant['last_login_ip'] ?? ''), + 'loginDate' => (string) ($merchant['last_login_at'] ?? ''), + 'createBy' => '系统', + 'createTime' => (string) ($merchant['created_at'] ?? ''), + 'updateBy' => null, + 'updateTime' => (string) ($merchant['updated_at'] ?? ''), + 'admin' => false, + 'credential_status' => (int) ($credential->status ?? 0), + 'credential_status_text' => $isCredentialEnabled ? '已开通' : '未开通', + 'credential_last_used_at' => (string) ($credential->last_used_at ?? ''), + 'password_updated_at' => (string) ($merchant['password_updated_at'] ?? ''), + ]; + + return [ + 'merchant_id' => $merchantId, + 'merchant_no' => (string) ($merchant['merchant_no'] !== '' ? $merchant['merchant_no'] : trim($merchantNo)), + 'merchant' => $merchant, + 'user' => $user, + 'roles' => ['common'], + 'permissions' => [], + ]; + } + + /** + * 校验商户登录 token,并返回商户与登录态信息。 + */ + public function authenticateToken(string $token, string $ip = '', string $userAgent = ''): ?array + { + $result = $this->jwtTokenManager->verify('merchant', $token, $ip, $userAgent); + if ($result === null) { + return null; + } + + $merchantId = (int) ($result['session']['merchant_id'] ?? $result['claims']['merchant_id'] ?? 0); + if ($merchantId <= 0) { + return null; + } + + /** @var Merchant|null $merchant */ + $merchant = $this->merchantRepository->find($merchantId); + if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) { + return null; + } + + $credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); + + return [ + 'merchant' => $merchant, + 'credential' => $credential, + ]; + } + + /** + * 校验商户登录凭证并签发 JWT。 + */ + public function authenticateCredentials(string $merchantNo, string $password, string $ip = '', string $userAgent = ''): array + { + $merchantNo = trim($merchantNo); + $password = trim($password); + if ($merchantNo === '' || $password === '') { + throw new ValidationException('商户号或密码错误'); + } + + /** @var Merchant|null $merchant */ + $merchant = $this->merchantRepository->findByMerchantNo($merchantNo); + if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) { + throw new ValidationException('商户号或密码错误'); + } + + if (!password_verify($password, (string) $merchant->password_hash)) { + throw new ValidationException('商户号或密码错误'); + } + + $this->merchantRepository->updateById((int) $merchant->id, [ + 'last_login_at' => $this->now(), + 'last_login_ip' => trim($ip), + ]); + + return $this->issueToken((int) $merchant->id, 86400, $ip, $userAgent); + } + + /** + * 撤销当前商户登录 token。 + */ + public function revokeToken(string $token): bool + { + return $this->jwtTokenManager->revoke('merchant', $token); + } + + /** + * 签发新的商户登录 token。 + */ + public function issueToken(int $merchantId, int $ttlSeconds = 86400, string $ip = '', string $userAgent = ''): array + { + /** @var Merchant|null $merchant */ + $merchant = $this->merchantRepository->find($merchantId); + if (!$merchant) { + throw new ValidationException('商户不存在'); + } + + $credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); + + $issued = $this->jwtTokenManager->issue('merchant', [ + 'sub' => (string) $merchantId, + 'merchant_id' => (int) $merchant->id, + 'merchant_no' => (string) $merchant->merchant_no, + ], [ + 'merchant_id' => (int) $merchant->id, + 'merchant_no' => (string) $merchant->merchant_no, + 'last_login_ip' => $ip, + 'user_agent' => $userAgent, + ], $ttlSeconds); + + return [ + 'token' => $issued['token'], + 'expires_in' => $issued['expires_in'], + 'merchant' => $merchant, + 'credential' => $credential ? [ + 'status' => (int) ($credential->status ?? 0), + 'sign_type' => (int) ($credential->sign_type ?? 0), + 'last_used_at' => $credential->last_used_at, + ] : null, + ]; + } +} + diff --git a/app/service/merchant/group/MerchantGroupService.php b/app/service/merchant/group/MerchantGroupService.php new file mode 100644 index 0000000..0e58b42 --- /dev/null +++ b/app/service/merchant/group/MerchantGroupService.php @@ -0,0 +1,120 @@ +merchantGroupRepository->enabledList(['id', 'group_name']) + ->map(function (MerchantGroup $group): array { + return [ + 'label' => (string) $group->group_name, + 'value' => (int) $group->id, + ]; + }) + ->values() + ->all(); + } + + /** + * 分页查询商户分组。 + */ + public function paginate(array $filters = [], int $page = 1, int $pageSize = 10) + { + $query = $this->merchantGroupRepository->query(); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where('group_name', 'like', '%' . $keyword . '%'); + } + + $groupName = trim((string) ($filters['group_name'] ?? '')); + if ($groupName !== '') { + $query->where('group_name', 'like', '%' . $groupName . '%'); + } + + if (array_key_exists('status', $filters) && $filters['status'] !== '') { + $query->where('status', (int) $filters['status']); + } + + return $query + ->orderByDesc('id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + } + + /** + * 根据 ID 查询商户分组。 + */ + public function findById(int $id): ?MerchantGroup + { + return $this->merchantGroupRepository->find($id); + } + + /** + * 新增商户分组。 + */ + public function create(array $data): MerchantGroup + { + $this->assertGroupNameUnique((string) ($data['group_name'] ?? '')); + return $this->merchantGroupRepository->create($data); + } + + /** + * 更新商户分组。 + */ + public function update(int $id, array $data): ?MerchantGroup + { + $this->assertGroupNameUnique((string) ($data['group_name'] ?? ''), $id); + if (!$this->merchantGroupRepository->updateById($id, $data)) { + return null; + } + + return $this->merchantGroupRepository->find($id); + } + + /** + * 删除商户分组。 + */ + public function delete(int $id): bool + { + return $this->merchantGroupRepository->deleteById($id); + } + + /** + * 校验商户分组名称唯一。 + */ + private function assertGroupNameUnique(string $groupName, int $ignoreId = 0): void + { + $groupName = trim($groupName); + if ($groupName === '') { + return; + } + + if ($this->merchantGroupRepository->existsByGroupName($groupName, $ignoreId)) { + throw new ValidationException('分组名称已存在'); + } + } +} + diff --git a/app/service/merchant/policy/MerchantPolicyService.php b/app/service/merchant/policy/MerchantPolicyService.php new file mode 100644 index 0000000..cba1d70 --- /dev/null +++ b/app/service/merchant/policy/MerchantPolicyService.php @@ -0,0 +1,161 @@ +merchantRepository->query() + ->from('ma_merchant as m') + ->leftJoin('ma_merchant_group as g', 'm.group_id', '=', 'g.id') + ->leftJoin('ma_merchant_policy as p', 'm.id', '=', 'p.merchant_id') + ->select([ + 'm.id as merchant_id', + 'm.merchant_no', + 'm.merchant_name', + 'm.merchant_short_name', + 'm.group_id', + 'm.status as merchant_status', + 'g.group_name', + 'p.id', + 'p.settlement_cycle_override', + 'p.auto_payout', + 'p.min_settlement_amount', + 'p.retry_policy_json', + 'p.route_policy_json', + 'p.fee_rule_override_json', + 'p.risk_policy_json', + 'p.remark', + 'p.created_at', + 'p.updated_at', + ]); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('m.merchant_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%') + ->orWhere('g.group_name', 'like', '%' . $keyword . '%'); + }); + } + + if (($merchantId = (int) ($filters['merchant_id'] ?? 0)) > 0) { + $query->where('m.id', $merchantId); + } + + if (($groupId = (int) ($filters['group_id'] ?? 0)) > 0) { + $query->where('m.group_id', $groupId); + } + + if (($hasPolicy = (string) ($filters['has_policy'] ?? '')) !== '') { + if ((int) $hasPolicy === 1) { + $query->whereNotNull('p.id'); + } else { + $query->whereNull('p.id'); + } + } + + if (($settlementCycle = (string) ($filters['settlement_cycle_override'] ?? '')) !== '') { + $query->where('p.settlement_cycle_override', (int) $settlementCycle); + } + + if (($autoPayout = (string) ($filters['auto_payout'] ?? '')) !== '') { + $query->where('p.auto_payout', (int) $autoPayout); + } + + $paginator = $query + ->orderByDesc('m.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + $paginator->getCollection()->transform(function ($row) { + $row->has_policy = $row->id ? 1 : 0; + $row->has_policy_text = $row->id ? '已配置' : '未配置'; + $row->settlement_cycle_text = $this->settlementCycleText((int) ($row->settlement_cycle_override ?? 0)); + $row->auto_payout_text = (int) ($row->auto_payout ?? 0) === 1 ? '是' : '否'; + $row->min_settlement_amount_text = $this->formatAmount((int) ($row->min_settlement_amount ?? 0)); + $row->has_retry_policy = !empty((array) ($row->retry_policy_json ?? [])); + $row->has_route_policy = !empty((array) ($row->route_policy_json ?? [])); + $row->has_fee_rule_override = !empty((array) ($row->fee_rule_override_json ?? [])); + $row->has_risk_policy = !empty((array) ($row->risk_policy_json ?? [])); + + return $row; + }); + + return $paginator; + } + + /** + * 查询单个商户的策略。 + */ + public function findByMerchantId(int $merchantId): ?MerchantPolicy + { + return $this->merchantPolicyRepository->findByMerchantId($merchantId); + } + + /** + * 保存商户策略。 + */ + public function saveByMerchantId(int $merchantId, array $data): MerchantPolicy + { + if (!$this->merchantRepository->find($merchantId)) { + throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]); + } + + return $this->merchantPolicyRepository->updateOrCreate( + ['merchant_id' => $merchantId], + [ + 'merchant_id' => $merchantId, + 'settlement_cycle_override' => (int) ($data['settlement_cycle_override'] ?? 1), + 'auto_payout' => (int) ($data['auto_payout'] ?? 0), + 'min_settlement_amount' => (int) ($data['min_settlement_amount'] ?? 0), + 'retry_policy_json' => $data['retry_policy_json'] ?? [], + 'route_policy_json' => $data['route_policy_json'] ?? [], + 'fee_rule_override_json' => $data['fee_rule_override_json'] ?? [], + 'risk_policy_json' => $data['risk_policy_json'] ?? [], + 'remark' => (string) ($data['remark'] ?? ''), + ] + ); + } + + /** + * 删除商户策略。 + */ + public function deleteByMerchantId(int $merchantId): bool + { + return $this->merchantPolicyRepository->deleteWhere(['merchant_id' => $merchantId]) > 0; + } + + private function settlementCycleText(int $value): string + { + return match ($value) { + 0 => 'D0', + 1 => 'D1', + 2 => 'D7', + 3 => 'T1', + 4 => 'OTHER', + default => '未设置', + }; + } + +} + diff --git a/app/service/merchant/portal/MerchantPortalBalanceService.php b/app/service/merchant/portal/MerchantPortalBalanceService.php new file mode 100644 index 0000000..c81ed97 --- /dev/null +++ b/app/service/merchant/portal/MerchantPortalBalanceService.php @@ -0,0 +1,50 @@ +supportService->merchantSummary($merchantId); + $snapshot = $this->merchantAccountService->getBalanceSnapshot($merchantId); + + $snapshot['available_balance_text'] = $this->supportService->formatAmount((int) ($snapshot['available_balance'] ?? 0)); + $snapshot['frozen_balance_text'] = $this->supportService->formatAmount((int) ($snapshot['frozen_balance'] ?? 0)); + $snapshot['withdrawable_balance_text'] = $snapshot['available_balance_text']; + + return [ + 'merchant' => $merchant, + 'snapshot' => $snapshot, + ]; + } + + public function balanceFlows(array $filters, int $merchantId, int $page, int $pageSize): array + { + $filters['merchant_id'] = $merchantId; + $paginator = $this->merchantAccountLedgerService->paginate($filters, $page, $pageSize); + + return [ + 'merchant' => $this->supportService->merchantSummary($merchantId), + 'snapshot' => $this->withdrawableBalance($merchantId)['snapshot'], + 'list' => $paginator->items(), + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'size' => $paginator->perPage(), + ]; + } +} diff --git a/app/service/merchant/portal/MerchantPortalChannelQueryService.php b/app/service/merchant/portal/MerchantPortalChannelQueryService.php new file mode 100644 index 0000000..8511598 --- /dev/null +++ b/app/service/merchant/portal/MerchantPortalChannelQueryService.php @@ -0,0 +1,113 @@ +paymentChannelRepository->query() + ->from('ma_payment_channel as c') + ->leftJoin('ma_payment_type as t', 'c.pay_type_id', '=', 't.id') + ->select([ + 'c.id', + 'c.merchant_id', + 'c.name', + 'c.split_rate_bp', + 'c.cost_rate_bp', + 'c.channel_mode', + 'c.pay_type_id', + 'c.plugin_code', + 'c.api_config_id', + 'c.daily_limit_amount', + 'c.daily_limit_count', + 'c.min_amount', + 'c.max_amount', + 'c.remark', + 'c.status', + 'c.sort_no', + 'c.created_at', + 'c.updated_at', + ]) + ->selectRaw("COALESCE(t.code, '') AS pay_type_code") + ->selectRaw("COALESCE(t.name, '') AS pay_type_name") + ->where('c.merchant_id', $merchantId); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('c.name', 'like', '%' . $keyword . '%') + ->orWhere('c.plugin_code', 'like', '%' . $keyword . '%') + ->orWhere('t.name', 'like', '%' . $keyword . '%'); + }); + } + + $payTypeId = trim((string) ($filters['pay_type_id'] ?? '')); + if ($payTypeId !== '') { + $query->where('c.pay_type_id', (int) $payTypeId); + } + + $status = trim((string) ($filters['status'] ?? '')); + if ($status !== '') { + $query->where('c.status', (int) $status); + } + + $channelMode = trim((string) ($filters['channel_mode'] ?? '')); + if ($channelMode !== '') { + $query->where('c.channel_mode', (int) $channelMode); + } + + $paginator = $query + ->orderBy('c.sort_no') + ->orderByDesc('c.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + $paginator->getCollection()->transform(function ($row) { + return $this->decorateChannelRow($row); + }); + + return [ + 'merchant' => $this->supportService->merchantSummary($merchantId), + 'pay_types' => $this->supportService->enabledPayTypeOptions(), + 'list' => $paginator->items(), + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'size' => $paginator->perPage(), + ]; + } + + private function decorateChannelRow(object $row): object + { + $row->channel_mode_text = (string) (RouteConstant::channelModeMap()[(int) $row->channel_mode] ?? '未知'); + $row->status_text = (string) (CommonConstant::statusMap()[(int) $row->status] ?? '未知'); + $row->split_rate_text = $this->supportService->formatRate((int) $row->split_rate_bp); + $row->cost_rate_text = $this->supportService->formatRate((int) $row->cost_rate_bp); + $row->daily_limit_amount_text = $this->supportService->formatAmountOrUnlimited((int) $row->daily_limit_amount); + $row->daily_limit_count_text = $this->supportService->formatCountOrUnlimited((int) $row->daily_limit_count); + $row->min_amount_text = $this->supportService->formatAmountOrUnlimited((int) $row->min_amount); + $row->max_amount_text = $this->supportService->formatAmountOrUnlimited((int) $row->max_amount); + $row->created_at_text = $this->supportService->formatDateTime($row->created_at ?? null); + $row->updated_at_text = $this->supportService->formatDateTime($row->updated_at ?? null); + + return $row; + } +} diff --git a/app/service/merchant/portal/MerchantPortalChannelService.php b/app/service/merchant/portal/MerchantPortalChannelService.php new file mode 100644 index 0000000..73fbb1f --- /dev/null +++ b/app/service/merchant/portal/MerchantPortalChannelService.php @@ -0,0 +1,29 @@ +queryService->myChannels($filters, $merchantId, $page, $pageSize); + } + + public function routePreview(int $merchantId, int $payTypeId, int $payAmount, string $statDate = ''): array + { + return $this->routePreviewService->routePreview($merchantId, $payTypeId, $payAmount, $statDate); + } +} diff --git a/app/service/merchant/portal/MerchantPortalCredentialCommandService.php b/app/service/merchant/portal/MerchantPortalCredentialCommandService.php new file mode 100644 index 0000000..e4a4cbb --- /dev/null +++ b/app/service/merchant/portal/MerchantPortalCredentialCommandService.php @@ -0,0 +1,53 @@ +supportService->merchantSummary($merchantId); + $credentialValue = $this->merchantApiCredentialService->issueCredential($merchantId); + $credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); + + return [ + 'merchant' => $merchant, + 'credential_value' => $credentialValue, + 'credential' => $credential ? $this->formatCredential($credential, $merchant) : null, + ]; + } + + private function formatCredential(\app\model\merchant\MerchantApiCredential $credential, array $merchant): array + { + $signType = (int) $credential->sign_type; + + return [ + 'id' => (int) $credential->id, + 'merchant_id' => (int) $credential->merchant_id, + 'merchant_no' => (string) ($merchant['merchant_no'] ?? ''), + 'merchant_name' => (string) ($merchant['merchant_name'] ?? ''), + 'sign_type' => $signType, + 'sign_type_text' => $this->supportService->signTypeText($signType), + 'api_key_preview' => $this->supportService->maskCredentialValue((string) $credential->api_key), + 'status' => (int) $credential->status, + 'status_text' => (string) ($credential->status ? '启用' : '禁用'), + 'last_used_at' => $this->supportService->formatDateTime($credential->last_used_at ?? null), + 'created_at' => $this->supportService->formatDateTime($credential->created_at ?? null), + 'updated_at' => $this->supportService->formatDateTime($credential->updated_at ?? null), + ]; + } +} diff --git a/app/service/merchant/portal/MerchantPortalCredentialQueryService.php b/app/service/merchant/portal/MerchantPortalCredentialQueryService.php new file mode 100644 index 0000000..f84d12d --- /dev/null +++ b/app/service/merchant/portal/MerchantPortalCredentialQueryService.php @@ -0,0 +1,53 @@ +supportService->merchantSummary($merchantId); + $credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); + + return [ + 'merchant' => $merchant, + 'has_credential' => $credential !== null, + 'credential' => $credential ? $this->formatCredential($credential, $merchant) : null, + ]; + } + + private function formatCredential(MerchantApiCredential $credential, array $merchant): array + { + $signType = (int) $credential->sign_type; + $status = (int) $credential->status; + + return [ + 'id' => (int) $credential->id, + 'merchant_id' => (int) $credential->merchant_id, + 'merchant_no' => (string) ($merchant['merchant_no'] ?? ''), + 'merchant_name' => (string) ($merchant['merchant_name'] ?? ''), + 'sign_type' => $signType, + 'sign_type_text' => $this->supportService->signTypeText($signType), + 'api_key_preview' => $this->supportService->maskCredentialValue((string) $credential->api_key), + 'status' => $status, + 'status_text' => (string) (CommonConstant::statusMap()[$status] ?? '未知'), + 'last_used_at' => $this->supportService->formatDateTime($credential->last_used_at ?? null), + 'created_at' => $this->supportService->formatDateTime($credential->created_at ?? null), + 'updated_at' => $this->supportService->formatDateTime($credential->updated_at ?? null), + ]; + } +} diff --git a/app/service/merchant/portal/MerchantPortalCredentialService.php b/app/service/merchant/portal/MerchantPortalCredentialService.php new file mode 100644 index 0000000..2b14ba9 --- /dev/null +++ b/app/service/merchant/portal/MerchantPortalCredentialService.php @@ -0,0 +1,27 @@ +queryService->apiCredential($merchantId); + } + + public function issueCredential(int $merchantId): array + { + return $this->commandService->issueCredential($merchantId); + } +} diff --git a/app/service/merchant/portal/MerchantPortalFinanceService.php b/app/service/merchant/portal/MerchantPortalFinanceService.php new file mode 100644 index 0000000..e7bbb48 --- /dev/null +++ b/app/service/merchant/portal/MerchantPortalFinanceService.php @@ -0,0 +1,39 @@ +settlementService->settlementRecords($filters, $merchantId, $page, $pageSize); + } + + public function settlementRecordDetail(string $settleNo, int $merchantId): ?array + { + return $this->settlementService->settlementRecordDetail($settleNo, $merchantId); + } + + public function withdrawableBalance(int $merchantId): array + { + return $this->balanceService->withdrawableBalance($merchantId); + } + + public function balanceFlows(array $filters, int $merchantId, int $page, int $pageSize): array + { + return $this->balanceService->balanceFlows($filters, $merchantId, $page, $pageSize); + } +} diff --git a/app/service/merchant/portal/MerchantPortalProfileCommandService.php b/app/service/merchant/portal/MerchantPortalProfileCommandService.php new file mode 100644 index 0000000..c006b3d --- /dev/null +++ b/app/service/merchant/portal/MerchantPortalProfileCommandService.php @@ -0,0 +1,69 @@ +merchantRepository->find($merchantId); + if (!$merchant) { + throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]); + } + + $this->merchantRepository->updateById($merchantId, [ + 'merchant_short_name' => trim((string) ($data['merchant_short_name'] ?? $merchant->merchant_short_name)), + 'contact_name' => trim((string) ($data['contact_name'] ?? $merchant->contact_name)), + 'contact_phone' => trim((string) ($data['contact_phone'] ?? $merchant->contact_phone)), + 'contact_email' => trim((string) ($data['contact_email'] ?? $merchant->contact_email)), + 'settlement_account_name' => trim((string) ($data['settlement_account_name'] ?? $merchant->settlement_account_name)), + 'settlement_account_no' => trim((string) ($data['settlement_account_no'] ?? $merchant->settlement_account_no)), + 'settlement_bank_name' => trim((string) ($data['settlement_bank_name'] ?? $merchant->settlement_bank_name)), + 'settlement_bank_branch' => trim((string) ($data['settlement_bank_branch'] ?? $merchant->settlement_bank_branch)), + ]); + + return [ + 'merchant' => $this->supportService->merchantSummary($merchantId), + 'pay_types' => $this->supportService->enabledPayTypeOptions(), + ]; + } + + public function changePassword(int $merchantId, array $data): array + { + $merchant = $this->merchantRepository->find($merchantId); + if (!$merchant) { + throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]); + } + + $currentPassword = trim((string) ($data['current_password'] ?? '')); + $newPassword = trim((string) ($data['password'] ?? '')); + + if (!password_verify($currentPassword, (string) $merchant->password_hash)) { + throw new ValidationException('当前密码不正确'); + } + + $this->merchantRepository->updateById($merchantId, [ + 'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT), + 'password_updated_at' => $this->now(), + ]); + + return [ + 'updated' => true, + 'password_updated_at' => $this->supportService->formatDateTime($this->now()), + ]; + } +} diff --git a/app/service/merchant/portal/MerchantPortalProfileQueryService.php b/app/service/merchant/portal/MerchantPortalProfileQueryService.php new file mode 100644 index 0000000..4ad5e88 --- /dev/null +++ b/app/service/merchant/portal/MerchantPortalProfileQueryService.php @@ -0,0 +1,24 @@ + $this->supportService->merchantSummary($merchantId), + 'pay_types' => $this->supportService->enabledPayTypeOptions(), + ]; + } +} diff --git a/app/service/merchant/portal/MerchantPortalProfileService.php b/app/service/merchant/portal/MerchantPortalProfileService.php new file mode 100644 index 0000000..c2c9883 --- /dev/null +++ b/app/service/merchant/portal/MerchantPortalProfileService.php @@ -0,0 +1,32 @@ +queryService->profile($merchantId); + } + + public function updateProfile(int $merchantId, array $data): array + { + return $this->commandService->updateProfile($merchantId, $data); + } + + public function changePassword(int $merchantId, array $data): array + { + return $this->commandService->changePassword($merchantId, $data); + } +} diff --git a/app/service/merchant/portal/MerchantPortalRoutePreviewService.php b/app/service/merchant/portal/MerchantPortalRoutePreviewService.php new file mode 100644 index 0000000..93a6610 --- /dev/null +++ b/app/service/merchant/portal/MerchantPortalRoutePreviewService.php @@ -0,0 +1,178 @@ +supportService->merchantSummary($merchantId); + $statDate = trim($statDate) !== '' ? trim($statDate) : FormatHelper::timestamp(time(), 'Y-m-d'); + + $response = [ + 'merchant' => $merchant, + 'pay_types' => $this->supportService->enabledPayTypeOptions(), + 'pay_type_id' => $payTypeId, + 'pay_amount' => $payAmount, + 'pay_amount_text' => $this->supportService->formatAmount($payAmount), + 'stat_date' => $statDate, + 'available' => false, + 'reason' => '请选择支付方式和金额后预览路由', + 'merchant_group_id' => (int) ($merchant['merchant_group_id'] ?? 0), + 'merchant_group_name' => (string) ($merchant['merchant_group_name'] ?? ''), + 'bind' => null, + 'poll_group' => null, + 'selected_channel' => null, + 'candidates' => [], + ]; + + if ($payTypeId <= 0 || $payAmount <= 0) { + return $response; + } + + if ((int) $merchant['merchant_group_id'] <= 0) { + $response['reason'] = '当前商户未配置商户分组,无法预览路由'; + return $response; + } + + try { + $resolved = $this->paymentRouteService->resolveByMerchantGroup( + (int) $merchant['merchant_group_id'], + $payTypeId, + $payAmount, + ['stat_date' => $statDate] + ); + + $response['available'] = true; + $response['reason'] = '路由预览成功'; + $response['bind'] = $this->normalizeBind($resolved['bind'] ?? null); + $response['poll_group'] = $this->normalizePollGroup($resolved['poll_group'] ?? null); + $response['selected_channel'] = $this->normalizePreviewCandidate($resolved['selected_channel'] ?? null); + + $response['candidates'] = array_values(array_map( + fn (array $item) => $this->normalizePreviewCandidate($item), + (array) ($resolved['candidates'] ?? []) + )); + } catch (Throwable $e) { + $response['reason'] = $e->getMessage() !== '' ? $e->getMessage() : '路由预览失败'; + } + + return $response; + } + + private function normalizeBind(mixed $bind): ?array + { + $data = $this->supportService->normalizeModel($bind); + if ($data === null) { + return null; + } + + $status = (int) ($data['status'] ?? 0); + + return [ + 'merchant_group_id' => (int) ($data['merchant_group_id'] ?? 0), + 'pay_type_id' => (int) ($data['pay_type_id'] ?? 0), + 'poll_group_id' => (int) ($data['poll_group_id'] ?? 0), + 'status' => $status, + 'status_text' => (string) (CommonConstant::statusMap()[$status] ?? '未知'), + 'remark' => (string) ($data['remark'] ?? ''), + 'created_at' => $this->supportService->formatDateTime($data['created_at'] ?? null), + 'updated_at' => $this->supportService->formatDateTime($data['updated_at'] ?? null), + ]; + } + + private function normalizePollGroup(mixed $pollGroup): ?array + { + $data = $this->supportService->normalizeModel($pollGroup); + if ($data === null) { + return null; + } + + $routeMode = (int) ($data['route_mode'] ?? 0); + $status = (int) ($data['status'] ?? 0); + + return [ + 'id' => (int) ($data['id'] ?? 0), + 'group_name' => (string) ($data['group_name'] ?? ''), + 'pay_type_id' => (int) ($data['pay_type_id'] ?? 0), + 'route_mode' => $routeMode, + 'route_mode_text' => (string) (RouteConstant::routeModeMap()[$routeMode] ?? '未知'), + 'status' => $status, + 'status_text' => (string) (CommonConstant::statusMap()[$status] ?? '未知'), + 'remark' => (string) ($data['remark'] ?? ''), + 'created_at' => $this->supportService->formatDateTime($data['created_at'] ?? null), + 'updated_at' => $this->supportService->formatDateTime($data['updated_at'] ?? null), + ]; + } + + private function normalizePreviewCandidate(mixed $candidate): ?array + { + $data = is_array($candidate) ? $candidate : $this->supportService->normalizeModel($candidate); + if ($data === null) { + return null; + } + + $channel = $this->supportService->normalizeModel($data['channel'] ?? null) ?? []; + $pollGroupChannel = $this->supportService->normalizeModel($data['poll_group_channel'] ?? null) ?? []; + $dailyStat = $this->supportService->normalizeModel($data['daily_stat'] ?? null) ?? []; + + $channelMode = (int) ($channel['channel_mode'] ?? 0); + $status = (int) ($channel['status'] ?? 0); + $payTypeId = (int) ($channel['pay_type_id'] ?? 0); + + return [ + 'channel_id' => (int) ($channel['id'] ?? 0), + 'channel_name' => (string) ($channel['name'] ?? ''), + 'pay_type_id' => $payTypeId, + 'pay_type_name' => $this->supportService->paymentTypeName($payTypeId), + 'channel_mode' => $channelMode, + 'channel_mode_text' => (string) (RouteConstant::channelModeMap()[$channelMode] ?? '未知'), + 'status' => $status, + 'status_text' => (string) (CommonConstant::statusMap()[$status] ?? '未知'), + 'plugin_code' => (string) ($channel['plugin_code'] ?? ''), + 'sort_no' => (int) ($pollGroupChannel['sort_no'] ?? 0), + 'weight' => (int) ($pollGroupChannel['weight'] ?? 0), + 'is_default' => (int) ($pollGroupChannel['is_default'] ?? 0), + 'health_score' => (int) ($dailyStat['health_score'] ?? 0), + 'health_score_text' => (string) ($dailyStat['health_score'] ?? 0), + 'success_rate_bp' => (int) ($dailyStat['success_rate_bp'] ?? 0), + 'success_rate_text' => $this->supportService->formatRate((int) ($dailyStat['success_rate_bp'] ?? 0)), + 'avg_latency_ms' => (int) ($dailyStat['avg_latency_ms'] ?? 0), + 'avg_latency_text' => $this->supportService->formatLatency((int) ($dailyStat['avg_latency_ms'] ?? 0)), + 'split_rate_bp' => (int) ($channel['split_rate_bp'] ?? 0), + 'split_rate_text' => $this->supportService->formatRate((int) ($channel['split_rate_bp'] ?? 0)), + 'cost_rate_bp' => (int) ($channel['cost_rate_bp'] ?? 0), + 'cost_rate_text' => $this->supportService->formatRate((int) ($channel['cost_rate_bp'] ?? 0)), + 'daily_limit_amount' => (int) ($channel['daily_limit_amount'] ?? 0), + 'daily_limit_amount_text' => $this->supportService->formatAmountOrUnlimited((int) ($channel['daily_limit_amount'] ?? 0)), + 'daily_limit_count' => (int) ($channel['daily_limit_count'] ?? 0), + 'daily_limit_count_text' => $this->supportService->formatCountOrUnlimited((int) ($channel['daily_limit_count'] ?? 0)), + 'min_amount' => (int) ($channel['min_amount'] ?? 0), + 'min_amount_text' => $this->supportService->formatAmountOrUnlimited((int) ($channel['min_amount'] ?? 0)), + 'max_amount' => (int) ($channel['max_amount'] ?? 0), + 'max_amount_text' => $this->supportService->formatAmountOrUnlimited((int) ($channel['max_amount'] ?? 0)), + 'remark' => (string) ($channel['remark'] ?? ''), + 'created_at' => $this->supportService->formatDateTime($channel['created_at'] ?? null), + 'updated_at' => $this->supportService->formatDateTime($channel['updated_at'] ?? null), + ]; + } +} diff --git a/app/service/merchant/portal/MerchantPortalService.php b/app/service/merchant/portal/MerchantPortalService.php new file mode 100644 index 0000000..ae6215c --- /dev/null +++ b/app/service/merchant/portal/MerchantPortalService.php @@ -0,0 +1,76 @@ +profileService->profile($merchantId); + } + + public function updateProfile(int $merchantId, array $data): array + { + return $this->profileService->updateProfile($merchantId, $data); + } + + public function changePassword(int $merchantId, array $data): array + { + return $this->profileService->changePassword($merchantId, $data); + } + + public function myChannels(array $filters, int $merchantId, int $page, int $pageSize): array + { + return $this->channelService->myChannels($filters, $merchantId, $page, $pageSize); + } + + public function routePreview(int $merchantId, int $payTypeId, int $payAmount, string $statDate = ''): array + { + return $this->channelService->routePreview($merchantId, $payTypeId, $payAmount, $statDate); + } + + public function apiCredential(int $merchantId): array + { + return $this->credentialService->apiCredential($merchantId); + } + + public function issueCredential(int $merchantId): array + { + return $this->credentialService->issueCredential($merchantId); + } + + public function settlementRecords(array $filters, int $merchantId, int $page, int $pageSize): array + { + return $this->financeService->settlementRecords($filters, $merchantId, $page, $pageSize); + } + + public function settlementRecordDetail(string $settleNo, int $merchantId): ?array + { + return $this->financeService->settlementRecordDetail($settleNo, $merchantId); + } + + public function withdrawableBalance(int $merchantId): array + { + return $this->financeService->withdrawableBalance($merchantId); + } + + public function balanceFlows(array $filters, int $merchantId, int $page, int $pageSize): array + { + return $this->financeService->balanceFlows($filters, $merchantId, $page, $pageSize); + } +} diff --git a/app/service/merchant/portal/MerchantPortalSettlementService.php b/app/service/merchant/portal/MerchantPortalSettlementService.php new file mode 100644 index 0000000..04c721b --- /dev/null +++ b/app/service/merchant/portal/MerchantPortalSettlementService.php @@ -0,0 +1,47 @@ +settlementOrderQueryService->paginate($filters, $page, $pageSize, $merchantId); + + return [ + 'merchant' => $this->supportService->merchantSummary($merchantId), + 'list' => $paginator->items(), + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'size' => $paginator->perPage(), + ]; + } + + public function settlementRecordDetail(string $settleNo, int $merchantId): ?array + { + try { + $detail = $this->settlementOrderQueryService->detail($settleNo, $merchantId); + } catch (ResourceNotFoundException) { + return null; + } + + return [ + 'merchant' => $this->supportService->merchantSummary($merchantId), + 'settlement_order' => $detail['settlement_order'] ?? null, + 'timeline' => $detail['timeline'] ?? [], + ]; + } +} diff --git a/app/service/merchant/portal/MerchantPortalSupportService.php b/app/service/merchant/portal/MerchantPortalSupportService.php new file mode 100644 index 0000000..041b2a4 --- /dev/null +++ b/app/service/merchant/portal/MerchantPortalSupportService.php @@ -0,0 +1,201 @@ +merchantService->ensureMerchantEnabled($merchantId); + + $row = $this->merchantRepository->query() + ->from('ma_merchant as m') + ->leftJoin('ma_merchant_group as g', 'm.group_id', '=', 'g.id') + ->select([ + 'm.id', + 'm.merchant_no', + 'm.merchant_name', + 'm.merchant_short_name', + 'm.merchant_type', + 'm.group_id', + 'm.risk_level', + 'm.contact_name', + 'm.contact_phone', + 'm.contact_email', + 'm.settlement_account_name', + 'm.settlement_account_no', + 'm.settlement_bank_name', + 'm.settlement_bank_branch', + 'm.status', + 'm.last_login_at', + 'm.last_login_ip', + 'm.password_updated_at', + 'm.remark', + 'm.created_at', + 'm.updated_at', + ]) + ->selectRaw("COALESCE(g.group_name, '未分组') AS merchant_group_name") + ->selectRaw("COALESCE(m.settlement_account_name, '') AS settlement_account_name_text") + ->selectRaw("CASE WHEN m.settlement_account_no IS NULL OR m.settlement_account_no = '' THEN '' ELSE CONCAT(LEFT(m.settlement_account_no, 4), '****', RIGHT(m.settlement_account_no, 4)) END AS settlement_account_no_masked") + ->selectRaw("COALESCE(m.settlement_bank_name, '') AS settlement_bank_name_text") + ->selectRaw("COALESCE(m.settlement_bank_branch, '') AS settlement_bank_branch_text") + ->selectRaw("CASE m.merchant_type WHEN 0 THEN '个人' WHEN 1 THEN '企业' ELSE '其他' END AS merchant_type_text") + ->selectRaw("CASE m.risk_level WHEN 0 THEN '低' WHEN 1 THEN '中' WHEN 2 THEN '高' ELSE '未知' END AS risk_level_text") + ->selectRaw("CASE m.status WHEN 0 THEN '停用' WHEN 1 THEN '启用' ELSE '未知' END AS status_text") + ->where('m.id', $merchantId) + ->first(); + + if (!$row) { + throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]); + } + + return [ + 'id' => (int) $row->id, + 'merchant_id' => (int) $row->id, + 'merchant_no' => (string) $row->merchant_no, + 'merchant_name' => (string) $row->merchant_name, + 'merchant_short_name' => (string) $row->merchant_short_name, + 'merchant_type' => (int) $row->merchant_type, + 'merchant_type_text' => (string) $row->merchant_type_text, + 'merchant_group_id' => (int) $row->group_id, + 'merchant_group_name' => (string) $row->merchant_group_name, + 'risk_level' => (int) $row->risk_level, + 'risk_level_text' => (string) $row->risk_level_text, + 'contact_name' => (string) $row->contact_name, + 'contact_phone' => (string) $row->contact_phone, + 'contact_email' => (string) $row->contact_email, + 'settlement_account_name' => (string) $row->settlement_account_name, + 'settlement_account_no' => (string) $row->settlement_account_no, + 'settlement_bank_name' => (string) $row->settlement_bank_name, + 'settlement_bank_branch' => (string) $row->settlement_bank_branch, + 'settlement_account_name_text' => (string) $row->settlement_account_name_text, + 'settlement_account_no_masked' => (string) $row->settlement_account_no_masked, + 'settlement_bank_name_text' => (string) $row->settlement_bank_name_text, + 'settlement_bank_branch_text' => (string) $row->settlement_bank_branch_text, + 'status' => (int) $row->status, + 'status_text' => (string) $row->status_text, + 'last_login_at' => $this->formatDateTime($row->last_login_at ?? null), + 'last_login_ip' => (string) ($row->last_login_ip ?? ''), + 'password_updated_at' => $this->formatDateTime($row->password_updated_at ?? null), + 'remark' => (string) $row->remark, + 'created_at' => $this->formatDateTime($row->created_at ?? null), + 'updated_at' => $this->formatDateTime($row->updated_at ?? null), + ]; + } + + /** + * 启用的支付方式选项。 + */ + public function enabledPayTypeOptions(): array + { + return $this->paymentTypeService->enabledOptions(); + } + + /** + * 根据支付方式 ID 获取名称。 + */ + public function paymentTypeName(int $payTypeId): string + { + foreach ($this->paymentTypeService->enabledOptions() as $option) { + if ((int) ($option['value'] ?? 0) === $payTypeId) { + return (string) ($option['label'] ?? ''); + } + } + + return $payTypeId > 0 ? '未知' : ''; + } + + /** + * 格式化金额,单位为元。 + */ + public function formatAmount(int $amount): string + { + return parent::formatAmount($amount); + } + + /** + * 格式化金额,0 时显示不限。 + */ + public function formatAmountOrUnlimited(int $amount): string + { + return parent::formatAmountOrUnlimited($amount); + } + + /** + * 格式化次数,0 时显示不限。 + */ + public function formatCountOrUnlimited(int $count): string + { + return parent::formatCountOrUnlimited($count); + } + + /** + * 格式化费率,单位为百分点。 + */ + public function formatRate(int $basisPoints): string + { + return parent::formatRate($basisPoints); + } + + /** + * 格式化延迟。 + */ + public function formatLatency(int $latencyMs): string + { + return parent::formatLatency($latencyMs); + } + + /** + * 格式化日期时间。 + */ + public function formatDateTime(mixed $value, string $emptyText = ''): string + { + return parent::formatDateTime($value, $emptyText); + } + + /** + * 归一化模型对象,兼容模型和数组。 + */ + public function normalizeModel(mixed $value): ?array + { + return parent::normalizeModel($value); + } + + /** + * 隐藏接口凭证明文。 + */ + public function maskCredentialValue(string $credentialValue, bool $maskShortValue = true): string + { + return parent::maskCredentialValue($credentialValue, $maskShortValue); + } + + /** + * 签名类型文案。 + */ + public function signTypeText(int $signType): string + { + return $this->textFromMap($signType, AuthConstant::signTypeMap()); + } +} diff --git a/app/service/merchant/security/MerchantApiCredentialQueryService.php b/app/service/merchant/security/MerchantApiCredentialQueryService.php new file mode 100644 index 0000000..c8a46b0 --- /dev/null +++ b/app/service/merchant/security/MerchantApiCredentialQueryService.php @@ -0,0 +1,128 @@ +baseQuery(true); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('m.merchant_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') + ->orWhere('c.api_key', 'like', '%' . $keyword . '%'); + }); + } + + $merchantId = (string) ($filters['merchant_id'] ?? ''); + if ($merchantId !== '') { + $query->where('c.merchant_id', (int) $merchantId); + } + + $status = (string) ($filters['status'] ?? ''); + if ($status !== '') { + $query->where('c.status', (int) $status); + } + + $paginator = $query + ->orderByDesc('c.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + $paginator->getCollection()->transform(function ($row) { + $row->sign_type_text = $this->textFromMap((int) $row->sign_type, AuthConstant::signTypeMap()); + $row->status_text = (int) $row->status === AuthConstant::LOGIN_STATUS_ENABLED ? '启用' : '禁用'; + + return $row; + }); + + return $paginator; + } + + /** + * 查询商户接口凭证详情。 + */ + public function findById(int $id): ?MerchantApiCredential + { + $row = $this->baseQuery(false)->where('c.id', $id)->first(); + return $this->decorateRow($row); + } + + /** + * 查询商户对应的接口凭证详情。 + */ + public function findByMerchantId(int $merchantId): ?MerchantApiCredential + { + $row = $this->baseQuery(false)->where('c.merchant_id', $merchantId)->first(); + return $this->decorateRow($row); + } + + /** + * 统一构造查询对象。 + */ + private function baseQuery(bool $maskCredentialValue = false) + { + $query = $this->merchantApiCredentialRepository->query() + ->from('ma_merchant_api_credential as c') + ->leftJoin('ma_merchant as m', 'c.merchant_id', '=', 'm.id') + ->select([ + 'c.id', + 'c.merchant_id', + 'c.sign_type', + 'c.status', + 'c.last_used_at', + 'c.created_at', + 'c.updated_at', + ]) + ->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no") + ->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name"); + + if ($maskCredentialValue) { + $query->selectRaw("CASE WHEN c.api_key IS NULL OR c.api_key = '' THEN '' ELSE CONCAT(LEFT(c.api_key, 4), '****', RIGHT(c.api_key, 4)) END AS api_key_preview"); + } else { + $query->addSelect('c.api_key'); + $query->selectRaw("COALESCE(c.api_key, '') AS api_key_full"); + } + + return $query; + } + + /** + * 给详情行补充展示字段。 + */ + private function decorateRow(mixed $row): ?MerchantApiCredential + { + if (!$row) { + return null; + } + + $row->api_key_preview = $this->maskCredentialValue((string) ($row->api_key ?? ''), false); + $row->sign_type_text = $this->textFromMap((int) $row->sign_type, AuthConstant::signTypeMap()); + $row->status_text = (int) $row->status === AuthConstant::LOGIN_STATUS_ENABLED ? '启用' : '禁用'; + + return $row; + } +} diff --git a/app/service/merchant/security/MerchantApiCredentialService.php b/app/service/merchant/security/MerchantApiCredentialService.php new file mode 100644 index 0000000..951602e --- /dev/null +++ b/app/service/merchant/security/MerchantApiCredentialService.php @@ -0,0 +1,265 @@ +merchantApiCredentialQueryService->paginate($filters, $page, $pageSize); + } + + /** + * 校验外部支付接口的 MD5 签名。 + * + * @return array{merchant:\app\model\merchant\Merchant,credential:\app\model\merchant\MerchantApiCredential} + */ + public function verifyMd5Sign(array $payload): array + { + $merchantId = (int) ($payload['pid'] ?? $payload['merchant_id'] ?? 0); + $sign = trim((string) ($payload['sign'] ?? '')); + $signType = strtoupper((string) ($payload['sign_type'] ?? 'MD5')); + $providedKey = trim((string) ($payload['key'] ?? '')); + + if ($merchantId <= 0 || $sign === '') { + throw new ValidationException('pid/sign 参数缺失'); + } + + if ($signType !== 'MD5') { + throw new ValidationException('仅支持 MD5 签名'); + } + + /** @var Merchant|null $merchant */ + $merchant = $this->merchantRepository->find($merchantId); + if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) { + throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]); + } + + /** @var MerchantApiCredential|null $credential */ + $credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); + if (!$credential || (int) $credential->status !== AuthConstant::LOGIN_STATUS_ENABLED) { + throw new ValidationException('商户接口凭证未开通'); + } + + if ($providedKey !== '' && !hash_equals((string) $credential->api_key, $providedKey)) { + throw new ValidationException('商户接口凭证错误'); + } + + $params = $payload; + unset($params['sign'], $params['sign_type'], $params['key']); + foreach ($params as $paramKey => $paramValue) { + if ($paramValue === '' || $paramValue === null) { + unset($params[$paramKey]); + } + } + ksort($params); + + $key = (string) $credential->api_key; + $query = []; + foreach ($params as $paramKey => $paramValue) { + $query[] = $paramKey . '=' . $paramValue; + } + $base = implode('&', $query) . $key; + $expected = md5($base); + + if (!hash_equals(strtolower($expected), strtolower($sign))) { + throw new ValidationException('签名验证失败'); + } + + $credential->last_used_at = $this->now(); + $credential->save(); + + return [ + 'merchant' => $merchant, + 'credential' => $credential, + ]; + } + + /** + * 为商户生成并保存一份新的接口凭证。 + * + * 返回值是明文接口凭证值,只会在调用时完整出现一次,后续仅保存脱敏展示。 + */ + public function issueCredential(int $merchantId): string + { + $merchant = $this->merchantRepository->find($merchantId); + if (!$merchant) { + throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]); + } + + $credentialValue = $this->generateCredentialValue(); + $this->merchantApiCredentialRepository->updateOrCreate( + ['merchant_id' => $merchantId], + [ + 'merchant_id' => $merchantId, + 'sign_type' => AuthConstant::API_SIGN_TYPE_MD5, + 'api_key' => $credentialValue, + 'status' => AuthConstant::LOGIN_STATUS_ENABLED, + ] + ); + + return $credentialValue; + } + + /** + * 查询商户接口凭证详情。 + */ + public function findById(int $id): ?MerchantApiCredential + { + return $this->merchantApiCredentialQueryService->findById($id); + } + + /** + * 查询商户对应的接口凭证详情。 + */ + public function findByMerchantId(int $merchantId): ?MerchantApiCredential + { + return $this->merchantApiCredentialQueryService->findByMerchantId($merchantId); + } + + /** + * 新增或更新商户接口凭证。 + */ + public function create(array $data): MerchantApiCredential + { + $merchantId = (int) ($data['merchant_id'] ?? 0); + $merchant = $this->merchantRepository->find($merchantId); + if (!$merchant) { + throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]); + } + + $current = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); + if ($current) { + $updated = $this->update((int) $current->id, $data); + if ($updated) { + return $updated; + } + } + + return $this->merchantApiCredentialRepository->create($this->normalizePayload($data, false)); + } + + /** + * 修改商户接口凭证。 + */ + public function update(int $id, array $data): ?MerchantApiCredential + { + $current = $this->merchantApiCredentialRepository->find($id); + if (!$current) { + return null; + } + + $payload = $this->normalizePayload($data, true, $current); + if (!$this->merchantApiCredentialRepository->updateById($id, $payload)) { + return null; + } + + return $this->findById($id); + } + + /** + * 删除商户接口凭证。 + */ + public function delete(int $id): bool + { + return $this->merchantApiCredentialRepository->deleteById($id); + } + + /** + * 使用商户 ID 和接口凭证直接进行身份校验。 + * + * 该方法用于兼容 epay 风格的查询接口,不涉及签名串验签。 + * + * @return array{merchant:\app\model\merchant\Merchant,credential:\app\model\merchant\MerchantApiCredential} + */ + public function authenticateByKey(int $merchantId, string $key): array + { + if ($merchantId <= 0 || $key === '') { + throw new ValidationException('pid/key 参数缺失'); + } + + /** @var Merchant|null $merchant */ + $merchant = $this->merchantRepository->find($merchantId); + if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) { + throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]); + } + + /** @var MerchantApiCredential|null $credential */ + $credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); + if (!$credential || (int) $credential->status !== AuthConstant::LOGIN_STATUS_ENABLED) { + throw new ValidationException('商户接口凭证未开通'); + } + + if (!hash_equals((string) $credential->api_key, $key)) { + throw new ValidationException('商户接口凭证错误'); + } + + $credential->last_used_at = $this->now(); + $credential->save(); + + return [ + 'merchant' => $merchant, + 'credential' => $credential, + ]; + } + + /** + * 整理写入字段。 + */ + private function normalizePayload(array $data, bool $isUpdate, ?MerchantApiCredential $current = null): array + { + $merchantId = (int) ($current?->merchant_id ?? ($data['merchant_id'] ?? 0)); + $payload = [ + 'merchant_id' => $merchantId, + 'sign_type' => (int) ($data['sign_type'] ?? AuthConstant::API_SIGN_TYPE_MD5), + 'status' => (int) ($data['status'] ?? AuthConstant::LOGIN_STATUS_ENABLED), + ]; + + $apiKey = trim((string) ($data['api_key'] ?? '')); + if ($apiKey !== '') { + $payload['api_key'] = $apiKey; + } elseif (!$isUpdate) { + $payload['api_key'] = $this->generateCredentialValue(); + } + + return $payload; + } + + /** + * 生成新的接口凭证值。 + */ + private function generateCredentialValue(): string + { + return bin2hex(random_bytes(16)); + } + +} + diff --git a/app/service/ops/log/ChannelNotifyLogService.php b/app/service/ops/log/ChannelNotifyLogService.php new file mode 100644 index 0000000..c57ab21 --- /dev/null +++ b/app/service/ops/log/ChannelNotifyLogService.php @@ -0,0 +1,154 @@ +baseQuery(); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('n.notify_no', 'like', '%' . $keyword . '%') + ->orWhere('n.biz_no', 'like', '%' . $keyword . '%') + ->orWhere('n.pay_no', 'like', '%' . $keyword . '%') + ->orWhere('n.channel_request_no', 'like', '%' . $keyword . '%') + ->orWhere('n.channel_trade_no', 'like', '%' . $keyword . '%') + ->orWhere('n.last_error', 'like', '%' . $keyword . '%') + ->orWhere('p.merchant_order_no', 'like', '%' . $keyword . '%') + ->orWhere('p.subject', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%') + ->orWhere('g.group_name', 'like', '%' . $keyword . '%') + ->orWhere('c.name', 'like', '%' . $keyword . '%') + ->orWhere('c.plugin_code', 'like', '%' . $keyword . '%'); + }); + } + + $merchantId = (string) ($filters['merchant_id'] ?? ''); + if ($merchantId !== '') { + $query->where('p.merchant_id', (int) $merchantId); + } + + $channelId = (string) ($filters['channel_id'] ?? ''); + if ($channelId !== '') { + $query->where('n.channel_id', (int) $channelId); + } + + $notifyType = (string) ($filters['notify_type'] ?? ''); + if ($notifyType !== '') { + $query->where('n.notify_type', (int) $notifyType); + } + + $verifyStatus = (string) ($filters['verify_status'] ?? ''); + if ($verifyStatus !== '') { + $query->where('n.verify_status', (int) $verifyStatus); + } + + $processStatus = (string) ($filters['process_status'] ?? ''); + if ($processStatus !== '') { + $query->where('n.process_status', (int) $processStatus); + } + + $paginator = $query + ->orderByDesc('n.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + $paginator->getCollection()->transform(function ($row) { + return $this->decorateRow($row); + }); + + return $paginator; + } + + /** + * 按 ID 查询详情。 + */ + public function findById(int $id): ?ChannelNotifyLog + { + $row = $this->baseQuery() + ->where('n.id', $id) + ->first(); + + return $row ?: null; + } + + /** + * 格式化单条记录。 + */ + private function decorateRow(object $row): object + { + $row->notify_type_text = (string) (NotifyConstant::notifyTypeMap()[(int) $row->notify_type] ?? '未知'); + $row->verify_status_text = (string) (NotifyConstant::verifyStatusMap()[(int) $row->verify_status] ?? '未知'); + $row->process_status_text = (string) (NotifyConstant::processStatusMap()[(int) $row->process_status] ?? '未知'); + $row->next_retry_at_text = $this->formatDateTime($row->next_retry_at ?? null); + $row->created_at_text = $this->formatDateTime($row->created_at ?? null); + $row->updated_at_text = $this->formatDateTime($row->updated_at ?? null); + $row->raw_payload_text = $this->formatJson($row->raw_payload ?? null); + + return $row; + } + + /** + * 构建基础查询。 + */ + private function baseQuery() + { + return $this->channelNotifyLogRepository->query() + ->from('ma_channel_notify_log as n') + ->leftJoin('ma_pay_order as p', 'p.pay_no', '=', 'n.pay_no') + ->leftJoin('ma_merchant as m', 'm.id', '=', 'p.merchant_id') + ->leftJoin('ma_merchant_group as g', 'g.id', '=', 'm.group_id') + ->leftJoin('ma_payment_channel as c', 'c.id', '=', 'n.channel_id') + ->select([ + 'n.id', + 'n.notify_no', + 'n.channel_id', + 'n.notify_type', + 'n.biz_no', + 'n.pay_no', + 'n.channel_request_no', + 'n.channel_trade_no', + 'n.raw_payload', + 'n.verify_status', + 'n.process_status', + 'n.retry_count', + 'n.next_retry_at', + 'n.last_error', + 'n.created_at', + 'n.updated_at', + 'p.merchant_id', + 'p.merchant_order_no', + 'p.subject', + ]) + ->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no") + ->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name") + ->selectRaw("COALESCE(m.merchant_short_name, '') AS merchant_short_name") + ->selectRaw("COALESCE(g.group_name, '') AS merchant_group_name") + ->selectRaw("COALESCE(c.name, '') AS channel_name") + ->selectRaw("COALESCE(c.plugin_code, '') AS channel_plugin_code"); + } + +} diff --git a/app/service/ops/log/PayCallbackLogService.php b/app/service/ops/log/PayCallbackLogService.php new file mode 100644 index 0000000..5d85901 --- /dev/null +++ b/app/service/ops/log/PayCallbackLogService.php @@ -0,0 +1,141 @@ +baseQuery(); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('l.pay_no', 'like', '%' . $keyword . '%') + ->orWhere('p.merchant_order_no', 'like', '%' . $keyword . '%') + ->orWhere('p.subject', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%') + ->orWhere('g.group_name', 'like', '%' . $keyword . '%') + ->orWhere('c.name', 'like', '%' . $keyword . '%') + ->orWhere('c.plugin_code', 'like', '%' . $keyword . '%'); + }); + } + + $merchantId = (string) ($filters['merchant_id'] ?? ''); + if ($merchantId !== '') { + $query->where('p.merchant_id', (int) $merchantId); + } + + $channelId = (string) ($filters['channel_id'] ?? ''); + if ($channelId !== '') { + $query->where('l.channel_id', (int) $channelId); + } + + $callbackType = (string) ($filters['callback_type'] ?? ''); + if ($callbackType !== '') { + $query->where('l.callback_type', (int) $callbackType); + } + + $verifyStatus = (string) ($filters['verify_status'] ?? ''); + if ($verifyStatus !== '') { + $query->where('l.verify_status', (int) $verifyStatus); + } + + $processStatus = (string) ($filters['process_status'] ?? ''); + if ($processStatus !== '') { + $query->where('l.process_status', (int) $processStatus); + } + + $paginator = $query + ->orderByDesc('l.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + $paginator->getCollection()->transform(function ($row) { + return $this->decorateRow($row); + }); + + return $paginator; + } + + /** + * 按 ID 查询详情。 + */ + public function findById(int $id): ?PayCallbackLog + { + $row = $this->baseQuery() + ->where('l.id', $id) + ->first(); + + return $row ?: null; + } + + /** + * 格式化单条记录。 + */ + private function decorateRow(object $row): object + { + $row->callback_type_text = (string) (NotifyConstant::callbackTypeMap()[(int) $row->callback_type] ?? '未知'); + $row->verify_status_text = (string) (NotifyConstant::verifyStatusMap()[(int) $row->verify_status] ?? '未知'); + $row->process_status_text = (string) (NotifyConstant::processStatusMap()[(int) $row->process_status] ?? '未知'); + $row->created_at_text = $this->formatDateTime($row->created_at ?? null); + $row->request_data_text = $this->formatJson($row->request_data ?? null); + $row->process_result_text = $this->formatJson($row->process_result ?? null); + + return $row; + } + + /** + * 构建基础查询。 + */ + private function baseQuery() + { + return $this->payCallbackLogRepository->query() + ->from('ma_pay_callback_log as l') + ->leftJoin('ma_pay_order as p', 'p.pay_no', '=', 'l.pay_no') + ->leftJoin('ma_merchant as m', 'm.id', '=', 'p.merchant_id') + ->leftJoin('ma_merchant_group as g', 'g.id', '=', 'm.group_id') + ->leftJoin('ma_payment_channel as c', 'c.id', '=', 'l.channel_id') + ->select([ + 'l.id', + 'l.pay_no', + 'l.channel_id', + 'l.callback_type', + 'l.request_data', + 'l.verify_status', + 'l.process_status', + 'l.process_result', + 'l.created_at', + 'p.merchant_id', + 'p.merchant_order_no', + 'p.subject', + ]) + ->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no") + ->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name") + ->selectRaw("COALESCE(m.merchant_short_name, '') AS merchant_short_name") + ->selectRaw("COALESCE(g.group_name, '') AS merchant_group_name") + ->selectRaw("COALESCE(c.name, '') AS channel_name") + ->selectRaw("COALESCE(c.plugin_code, '') AS channel_plugin_code"); + } + +} diff --git a/app/service/ops/stat/ChannelDailyStatService.php b/app/service/ops/stat/ChannelDailyStatService.php new file mode 100644 index 0000000..3d85f8d --- /dev/null +++ b/app/service/ops/stat/ChannelDailyStatService.php @@ -0,0 +1,132 @@ +baseQuery(); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('s.stat_date', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%') + ->orWhere('g.group_name', 'like', '%' . $keyword . '%') + ->orWhere('c.name', 'like', '%' . $keyword . '%') + ->orWhere('c.plugin_code', 'like', '%' . $keyword . '%'); + }); + } + + $merchantId = (string) ($filters['merchant_id'] ?? ''); + if ($merchantId !== '') { + $query->where('s.merchant_id', (int) $merchantId); + } + + $channelId = (string) ($filters['channel_id'] ?? ''); + if ($channelId !== '') { + $query->where('s.channel_id', (int) $channelId); + } + + $statDate = trim((string) ($filters['stat_date'] ?? '')); + if ($statDate !== '') { + $query->where('s.stat_date', $statDate); + } + + $paginator = $query + ->orderByDesc('s.stat_date') + ->orderByDesc('s.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + $paginator->getCollection()->transform(function ($row) { + return $this->decorateRow($row); + }); + + return $paginator; + } + + /** + * 按 ID 查询详情。 + */ + public function findById(int $id): ?ChannelDailyStat + { + $row = $this->baseQuery() + ->where('s.id', $id) + ->first(); + + return $row ?: null; + } + + /** + * 格式化单条统计记录。 + */ + private function decorateRow(object $row): object + { + $row->pay_amount_text = $this->formatAmount((int) $row->pay_amount); + $row->refund_amount_text = $this->formatAmount((int) $row->refund_amount); + $row->success_rate_text = $this->formatRate((int) $row->success_rate_bp); + $row->avg_latency_ms_text = $this->formatLatency((int) $row->avg_latency_ms); + $row->stat_date_text = $this->formatDate($row->stat_date ?? null); + $row->created_at_text = $this->formatDateTime($row->created_at ?? null); + $row->updated_at_text = $this->formatDateTime($row->updated_at ?? null); + + return $row; + } + + /** + * 构建基础查询。 + */ + private function baseQuery() + { + return $this->channelDailyStatRepository->query() + ->from('ma_channel_daily_stat as s') + ->leftJoin('ma_merchant as m', 's.merchant_id', '=', 'm.id') + ->leftJoin('ma_merchant_group as g', 's.merchant_group_id', '=', 'g.id') + ->leftJoin('ma_payment_channel as c', 's.channel_id', '=', 'c.id') + ->select([ + 's.id', + 's.merchant_id', + 's.merchant_group_id', + 's.channel_id', + 's.stat_date', + 's.pay_success_count', + 's.pay_fail_count', + 's.pay_amount', + 's.refund_count', + 's.refund_amount', + 's.avg_latency_ms', + 's.success_rate_bp', + 's.health_score', + 's.created_at', + 's.updated_at', + ]) + ->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no") + ->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name") + ->selectRaw("COALESCE(m.merchant_short_name, '') AS merchant_short_name") + ->selectRaw("COALESCE(g.group_name, '') AS merchant_group_name") + ->selectRaw("COALESCE(c.name, '') AS channel_name") + ->selectRaw("COALESCE(c.plugin_code, '') AS channel_plugin_code"); + } + +} diff --git a/app/service/payment/compat/EpayCompatService.php b/app/service/payment/compat/EpayCompatService.php new file mode 100644 index 0000000..4ce9940 --- /dev/null +++ b/app/service/payment/compat/EpayCompatService.php @@ -0,0 +1,571 @@ +prepareSubmitAttempt($payload, $request); + $targetUrl = (string) ($attempt['cashier_url'] ?? ''); + + if ($targetUrl === '') { + throw new ValidationException('收银台跳转地址生成失败'); + } + + return redirect($targetUrl); + } catch (Throwable $e) { + return json([ + 'code' => 0, + 'msg' => $this->normalizeErrorMessage($e, '提交失败'), + ]); + } + } + + public function mapi(array $payload, Request $request): array + { + try { + $attempt = $this->prepareSubmitAttempt($payload, $request); + return $this->buildMapiResponse($attempt); + } catch (Throwable $e) { + return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e, '提交失败')]; + } + } + + public function api(array $payload): array + { + $act = strtolower(trim((string) ($payload['act'] ?? ''))); + if (!in_array($act, self::API_ACTIONS, true)) { + return ['code' => 0, 'msg' => '不支持的操作类型']; + } + + return match ($act) { + 'query' => $this->queryMerchantInfo($payload), + 'settle' => $this->querySettlementList($payload), + 'order' => $this->queryOrder($payload), + 'orders' => $this->queryOrders($payload), + 'refund' => $this->createRefund($payload), + }; + } + + public function queryMerchantInfo(array $payload): array + { + try { + $merchantId = (int) ($payload['pid'] ?? 0); + $key = trim((string) ($payload['key'] ?? '')); + $auth = $this->merchantApiCredentialService->authenticateByKey($merchantId, $key); + $merchant = $auth['merchant']; + $credential = $auth['credential']; + $account = $this->merchantAccountRepository->findByMerchantId($merchantId); + $todayDate = FormatHelper::timestamp(time(), 'Y-m-d'); + $lastDayDate = FormatHelper::timestamp(strtotime('-1 day'), 'Y-m-d'); + $totalOrders = (int) $this->payOrderRepository->query()->where('merchant_id', $merchantId)->count(); + $todayOrders = (int) $this->payOrderRepository->query()->where('merchant_id', $merchantId)->whereDate('created_at', $todayDate)->count(); + $lastDayOrders = (int) $this->payOrderRepository->query()->where('merchant_id', $merchantId)->whereDate('created_at', $lastDayDate)->count(); + + return [ + 'code' => 1, + 'pid' => (int) $merchant->id, + 'key' => (string) $credential->api_key, + 'active' => (int) $merchant->status, + 'money' => FormatHelper::amount((int) ($account->available_balance ?? 0)), + 'type' => $this->resolveMerchantSettlementType($merchant), + 'account' => (string) $merchant->settlement_account_no, + 'username' => (string) $merchant->settlement_account_name, + 'orders' => $totalOrders, + 'order_today' => $todayOrders, + 'order_lastday' => $lastDayOrders, + ]; + } catch (Throwable $e) { + return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e, '查询失败')]; + } + } + + public function querySettlementList(array $payload): array + { + try { + $merchantId = (int) ($payload['pid'] ?? 0); + $key = trim((string) ($payload['key'] ?? '')); + $this->merchantApiCredentialService->authenticateByKey($merchantId, $key); + $rows = $this->settlementOrderRepository->query()->where('merchant_id', $merchantId)->orderByDesc('id')->get(); + + return [ + 'code' => 1, + 'msg' => '查询结算记录成功!', + 'data' => $rows->map(function ($row): array { + return [ + 'settle_no' => (string) $row->settle_no, + 'cycle_type' => (int) $row->cycle_type, + 'cycle_key' => (string) $row->cycle_key, + 'status' => (int) $row->status, + 'gross_amount' => FormatHelper::amount((int) $row->gross_amount), + 'net_amount' => FormatHelper::amount((int) $row->net_amount), + 'accounted_amount' => FormatHelper::amount((int) $row->accounted_amount), + 'created_at' => FormatHelper::dateTime($row->created_at ?? null), + 'completed_at' => FormatHelper::dateTime($row->completed_at ?? null), + ]; + })->all(), + ]; + } catch (Throwable $e) { + return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e, '查询失败')]; + } + } + + public function queryOrder(array $payload): array + { + try { + $merchantId = (int) ($payload['pid'] ?? 0); + $key = trim((string) ($payload['key'] ?? '')); + $this->merchantApiCredentialService->authenticateByKey($merchantId, $key); + $context = $this->resolvePayOrderContext($merchantId, $payload); + if (!$context) { + return ['code' => 0, 'msg' => '订单不存在']; + } + + return ['code' => 1, 'msg' => '查询订单号成功!'] + $this->formatEpayOrderRow($context['pay_order'], $context['biz_order']); + } catch (Throwable $e) { + return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e, '查询失败')]; + } + } + + public function queryOrders(array $payload): array + { + try { + $merchantId = (int) ($payload['pid'] ?? 0); + $key = trim((string) ($payload['key'] ?? '')); + $this->merchantApiCredentialService->authenticateByKey($merchantId, $key); + $limit = min(50, max(1, (int) ($payload['limit'] ?? 20))); + $page = max(1, (int) ($payload['page'] ?? 1)); + $paginator = $this->payOrderRepository->query()->where('merchant_id', $merchantId)->orderByDesc('id')->paginate($limit, ['*'], 'page', $page); + + return [ + 'code' => 1, + 'msg' => '查询结算记录成功!', + 'data' => array_map(function ($row): array { + return $this->formatEpayOrderRow($row, $this->bizOrderRepository->findByBizNo((string) $row->biz_no)); + }, $paginator->items()), + ]; + } catch (Throwable $e) { + return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e, '查询失败')]; + } + } + + public function createRefund(array $payload): array + { + try { + $merchantId = (int) ($payload['pid'] ?? 0); + $key = trim((string) ($payload['key'] ?? '')); + $this->merchantApiCredentialService->authenticateByKey($merchantId, $key); + $context = $this->resolvePayOrderContext($merchantId, $payload); + if (!$context) { + return ['code' => 1, 'msg' => '订单不存在']; + } + + /** @var PayOrder $payOrder */ + $payOrder = $context['pay_order']; + $refundAmount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0')); + if ($refundAmount <= 0) { + return ['code' => 1, 'msg' => '退款金额不合法']; + } + + $refundOrder = $this->refundService->createRefund([ + 'pay_no' => (string) $payOrder->pay_no, + 'merchant_refund_no' => trim((string) ($payload['refund_no'] ?? $payload['merchant_refund_no'] ?? '')), + 'refund_amount' => $refundAmount, + 'reason' => trim((string) ($payload['reason'] ?? '')), + 'ext_json' => ['source' => 'epay'], + ]); + + $plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true); + $pluginResult = $plugin->refund([ + 'order_id' => (string) $payOrder->pay_no, + 'pay_no' => (string) $payOrder->pay_no, + 'biz_no' => (string) $payOrder->biz_no, + 'chan_order_no' => (string) $payOrder->channel_order_no, + 'chan_trade_no' => (string) $payOrder->channel_trade_no, + 'out_trade_no' => (string) $payOrder->channel_order_no, + 'refund_no' => (string) $refundOrder->refund_no, + 'refund_amount' => $refundAmount, + 'refund_reason' => trim((string) ($payload['reason'] ?? '')), + 'extra' => (array) ($payOrder->ext_json ?? []), + ]); + + if (!$this->isPluginSuccess($pluginResult)) { + $this->refundService->markRefundFailed((string) $refundOrder->refund_no, [ + 'failed_at' => $this->now(), + 'last_error' => (string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败'), + 'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult), + 'ext_json' => ['source' => 'epay'], + ]); + + return ['code' => 1, 'msg' => (string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败')]; + } + + $this->refundService->markRefundSuccess((string) $refundOrder->refund_no, [ + 'succeeded_at' => $this->now(), + 'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult), + 'ext_json' => ['source' => 'epay'], + ]); + + return ['code' => 0, 'msg' => '退款成功']; + } catch (Throwable $e) { + return ['code' => 1, 'msg' => $this->normalizeErrorMessage($e, '退款失败')]; + } + } + + private function prepareSubmitAttempt(array $payload, Request $request): array + { + $normalized = $this->normalizeSubmitPayload($payload, $request); + $result = $this->payOrderService->preparePayAttempt($normalized); + $payOrder = $result['pay_order']; + $payParams = (array) ($result['pay_params'] ?? []); + + return [ + 'normalized_payload' => $normalized, + 'result' => $result, + 'pay_order' => $payOrder, + 'pay_params' => $payParams, + 'cashier_url' => $this->buildCashierUrl((string) $payOrder->pay_no), + ]; + } + + private function normalizeSubmitPayload(array $payload, Request $request): array + { + $this->merchantApiCredentialService->verifyMd5Sign($payload); + $typeCode = trim((string) ($payload['type'] ?? '')); + $merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? '')); + $subject = trim((string) ($payload['name'] ?? '')); + $amount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0')); + $paymentType = $this->resolveSubmitPaymentType($typeCode); + + if ($merchantOrderNo === '') { + throw new ValidationException('out_trade_no 参数不能为空'); + } + if ($subject === '') { + throw new ValidationException('name 参数不能为空'); + } + if ($amount <= 0) { + throw new ValidationException('money 参数不合法'); + } + + $extJson = [ + 'epay_type' => $typeCode, + 'resolved_type' => (string) $paymentType->code, + 'notify_url' => trim((string) ($payload['notify_url'] ?? '')), + 'return_url' => trim((string) ($payload['return_url'] ?? '')), + 'param' => $this->normalizePayloadValue($payload['param'] ?? null), + 'clientip' => $this->resolveClientIp($payload, $request), + 'device' => $this->normalizeDeviceCode((string) ($payload['device'] ?? 'pc')), + 'sign_type' => strtoupper((string) ($payload['sign_type'] ?? 'MD5')), + 'submitted_type' => $typeCode, + 'submit_mode' => $typeCode === '' ? 'cashier' : 'direct', + 'request_method' => strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'POST')), + 'request_snapshot' => $this->normalizeRequestSnapshot($payload), + 'channel_callback_base_url' => (string) sys_config('site_url') . '/api/pay', + ]; + + return [ + 'merchant_id' => (int) ($payload['pid'] ?? 0), + 'merchant_order_no' => $merchantOrderNo, + 'pay_type_id' => (int) $paymentType->id, + 'pay_amount' => $amount, + 'subject' => $subject, + 'body' => $subject, + 'ext_json' => $extJson, + ]; + } + + private function resolveSubmitPaymentType(string $typeCode): PaymentType + { + $typeCode = trim($typeCode); + if ($typeCode === '') { + return $this->paymentTypeService->resolveEnabledType(''); + } + + $paymentType = $this->paymentTypeService->findByCode($typeCode); + if (!$paymentType || (int) $paymentType->status !== 1) { + throw new ValidationException('支付方式不支持'); + } + + return $paymentType; + } + + private function buildMapiResponse(array $attempt): array + { + /** @var PayOrder $payOrder */ + $payOrder = $attempt['pay_order']; + $payParams = (array) ($attempt['pay_params'] ?? []); + $cashierUrl = (string) ($attempt['cashier_url'] ?? $this->buildCashierUrl((string) $payOrder->pay_no)); + $payNo = (string) $payOrder->pay_no; + $response = ['code' => 1, 'msg' => '提交成功', 'trade_no' => $payNo]; + $type = (string) ($payParams['type'] ?? ''); + + if ($type === 'qrcode') { + $qrcode = $this->stringifyValue($payParams['qrcode_url'] ?? $payParams['qrcode_data'] ?? ''); + if ($qrcode !== '') { + $response['qrcode'] = $qrcode; + $response['payurl'] = $cashierUrl; + return $response; + } + } + + if ($type === 'urlscheme') { + $urlscheme = $this->stringifyValue($payParams['urlscheme'] ?? $payParams['order_str'] ?? ''); + if ($urlscheme !== '') { + $response['urlscheme'] = $urlscheme; + $response['payurl'] = $cashierUrl; + return $response; + } + } + + if ($type === 'url') { + $payUrl = $this->stringifyValue($payParams['payurl'] ?? ''); + if ($payUrl !== '') { + $response['payurl'] = $cashierUrl; + $response['origin_payurl'] = $payUrl; + return $response; + } + } + + if ($type === 'form' && $this->stringifyValue($payParams['html'] ?? '') !== '') { + $response['payurl'] = $cashierUrl; + return $response; + } + + if ($type === 'jsapi') { + $urlscheme = $this->stringifyValue($payParams['urlscheme'] ?? $payParams['order_str'] ?? ''); + if ($urlscheme !== '') { + $response['urlscheme'] = $urlscheme; + $response['payurl'] = $cashierUrl; + return $response; + } + } + + $fallback = $cashierUrl; + if ($fallback !== '') { + $response['payurl'] = $fallback; + } + + return $response; + } + + private function formatEpayOrderRow(PayOrder $payOrder, ?BizOrder $bizOrder = null): array + { + $bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + $extJson = (array) (($bizOrder?->ext_json) ?? []); + + return [ + 'trade_no' => (string) $payOrder->pay_no, + 'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? $extJson['merchant_order_no'] ?? ''), + 'api_trade_no' => (string) ($payOrder->channel_trade_no ?: $payOrder->channel_order_no ?: ''), + 'type' => $this->resolvePaymentTypeCode((int) $payOrder->pay_type_id), + 'pid' => (int) $payOrder->merchant_id, + 'addtime' => FormatHelper::dateTime($payOrder->created_at), + 'endtime' => FormatHelper::dateTime($payOrder->paid_at), + 'name' => (string) ($bizOrder?->subject ?? $extJson['subject'] ?? ''), + 'money' => FormatHelper::amount((int) $payOrder->pay_amount), + 'status' => (int) $payOrder->status === TradeConstant::ORDER_STATUS_SUCCESS ? 1 : 0, + 'param' => $this->stringifyValue($extJson['param'] ?? ''), + 'buyer' => $this->stringifyValue($extJson['buyer'] ?? ''), + ]; + } + + private function resolvePayOrderContext(int $merchantId, array $payload): ?array + { + $payNo = trim((string) ($payload['trade_no'] ?? '')); + $merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? '')); + $payOrder = null; + $bizOrder = null; + + if ($payNo !== '') { + $payOrder = $this->payOrderRepository->findByPayNo($payNo); + if ($payOrder) { + $bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + } + } + + if (!$payOrder && $merchantOrderNo !== '') { + $bizOrder = $this->bizOrderRepository->findByMerchantAndOrderNo($merchantId, $merchantOrderNo); + if ($bizOrder) { + $payOrder = $this->payOrderRepository->findLatestByBizNo((string) $bizOrder->biz_no); + } + } + + if (!$payOrder || (int) $payOrder->merchant_id !== $merchantId) { + return null; + } + + if (!$bizOrder) { + $bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + } + + return ['pay_order' => $payOrder, 'biz_order' => $bizOrder]; + } + + private function resolvePaymentTypeCode(int $payTypeId): string + { + return $this->paymentTypeService->resolveCodeById($payTypeId); + } + + private function resolveMerchantSettlementType(mixed $merchant): int + { + $bankName = strtolower(trim((string) ($merchant->settlement_bank_name ?? ''))); + $accountName = strtolower(trim((string) ($merchant->settlement_account_name ?? ''))); + $accountNo = strtolower(trim((string) ($merchant->settlement_account_no ?? ''))); + + if (str_contains($accountName, '支付宝') || str_contains($bankName, 'alipay') || str_contains($accountNo, 'alipay')) { + return 1; + } + + if (str_contains($accountName, '微信') || str_contains($bankName, 'wechat') || str_contains($accountNo, 'wechat')) { + return 2; + } + + if (str_contains($accountName, 'qq') || str_contains($bankName, 'qq') || str_contains($accountNo, 'qq')) { + return 3; + } + + if ($bankName !== '' || $accountNo !== '') { + return 4; + } + + return 4; + } + + private function parseMoneyToAmount(string $money): int + { + $money = trim($money); + if ($money === '' || !preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) { + return 0; + } + + return (int) round(((float) $money) * 100); + } + + private function resolveClientIp(array $payload, Request $request): string + { + $clientIp = trim((string) ($payload['clientip'] ?? '')); + if ($clientIp !== '') { + return $clientIp; + } + + return trim((string) $request->getRealIp()); + } + + private function normalizeDeviceCode(string $device): string + { + $device = strtolower(trim($device)); + return $device !== '' ? $device : 'pc'; + } + + private function normalizePayloadValue(mixed $value): mixed + { + if ($value === null) { + return null; + } + + if (is_array($value)) { + return $value; + } + + if (is_object($value) && method_exists($value, 'toArray')) { + $data = $value->toArray(); + return is_array($data) ? $data : null; + } + + return is_scalar($value) ? (string) $value : null; + } + + private function normalizeRequestSnapshot(array $payload): array + { + $snapshot = $payload; + unset($snapshot['sign'], $snapshot['key']); + unset($snapshot['submit_mode']); + return $snapshot; + } + + private function buildCashierUrl(string $payNo): string + { + return (string) sys_config('site_url') . '/pay/' . rawurlencode($payNo) . '/payment'; + } + + private function normalizeErrorMessage(Throwable $e, string $fallback): string + { + $message = trim((string) $e->getMessage()); + return $message !== '' ? $message : $fallback; + } + + private function isPluginSuccess(array $pluginResult): bool + { + return !array_key_exists('success', $pluginResult) || (bool) $pluginResult['success']; + } + + private function resolveRefundChannelNo(array $pluginResult, string $default = ''): string + { + foreach (['chan_refund_no', 'refund_no', 'trade_no', 'out_request_no'] as $key) { + if (array_key_exists($key, $pluginResult)) { + $value = $this->stringifyValue($pluginResult[$key]); + if ($value !== '') { + return $value; + } + } + } + + return $default; + } + + private function stringifyValue(mixed $value): string + { + if ($value === null) { + return ''; + } + if (is_string($value)) { + return trim($value); + } + if (is_int($value) || is_float($value) || is_bool($value)) { + return (string) $value; + } + if (is_array($value) || is_object($value)) { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return $json !== false ? $json : ''; + } + return (string) $value; + } + +} diff --git a/app/service/payment/config/PaymentChannelCommandService.php b/app/service/payment/config/PaymentChannelCommandService.php new file mode 100644 index 0000000..91daef8 --- /dev/null +++ b/app/service/payment/config/PaymentChannelCommandService.php @@ -0,0 +1,119 @@ +paymentChannelRepository->find($id); + } + + public function create(array $data): PaymentChannel + { + $this->assertChannelNameUnique((string) ($data['name'] ?? '')); + $this->assertMerchantExists($data); + $this->assertPluginSupportsPayType($data); + + return $this->paymentChannelRepository->create($data); + } + + public function update(int $id, array $data): ?PaymentChannel + { + $this->assertChannelNameUnique((string) ($data['name'] ?? ''), $id); + $this->assertMerchantExists($data); + $this->assertPluginSupportsPayType($data); + + if (!$this->paymentChannelRepository->updateById($id, $data)) { + return null; + } + + return $this->paymentChannelRepository->find($id); + } + + public function delete(int $id): bool + { + return $this->paymentChannelRepository->deleteById($id); + } + + private function assertMerchantExists(array $data): void + { + if (!array_key_exists('merchant_id', $data)) { + return; + } + + $merchantId = (int) $data['merchant_id']; + if ($merchantId === 0) { + return; + } + + if (!$this->merchantRepository->find($merchantId)) { + throw new PaymentException('所属商户不存在', 40209, [ + 'merchant_id' => $merchantId, + ]); + } + } + + private function assertPluginSupportsPayType(array $data): void + { + $pluginCode = trim((string) ($data['plugin_code'] ?? '')); + $payTypeId = (int) ($data['pay_type_id'] ?? 0); + + if ($pluginCode === '' || $payTypeId <= 0) { + return; + } + + $plugin = $this->paymentPluginRepository->findByCode($pluginCode); + $paymentType = $this->paymentTypeRepository->find($payTypeId); + + if (!$plugin || !$paymentType) { + return; + } + + $payTypes = is_array($plugin->pay_types) ? $plugin->pay_types : []; + $payTypeCodes = array_values(array_filter(array_map(static fn ($item) => trim((string) $item), $payTypes))); + $payTypeCode = trim((string) $paymentType->code); + + if ($payTypeCode === '' || !in_array($payTypeCode, $payTypeCodes, true)) { + throw new PaymentException('支付插件不支持当前支付方式', 40210, [ + 'plugin_code' => $pluginCode, + 'pay_type_code' => $payTypeCode, + ]); + } + } + + private function assertChannelNameUnique(string $name, int $ignoreId = 0): void + { + $name = trim($name); + if ($name === '') { + return; + } + + if ($this->paymentChannelRepository->existsByName($name, $ignoreId)) { + throw new PaymentException('通道名称已存在', 40215, [ + 'name' => $name, + 'ignore_id' => $ignoreId, + ]); + } + } +} diff --git a/app/service/payment/config/PaymentChannelQueryService.php b/app/service/payment/config/PaymentChannelQueryService.php new file mode 100644 index 0000000..2bb0ce7 --- /dev/null +++ b/app/service/payment/config/PaymentChannelQueryService.php @@ -0,0 +1,228 @@ +paymentChannelRepository->query() + ->from('ma_payment_channel as c') + ->where('c.status', CommonConstant::STATUS_ENABLED) + ->orderBy('c.sort_no') + ->orderByDesc('c.id') + ->get([ + 'c.id', + 'c.name', + ]) + ->map(function (PaymentChannel $channel): array { + return [ + 'label' => sprintf('%s(%d)', (string) $channel->name, (int) $channel->id), + 'value' => (int) $channel->id, + ]; + }) + ->values() + ->all(); + } + + public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array + { + $query = $this->paymentChannelRepository->query() + ->from('ma_payment_channel as c') + ->leftJoin('ma_merchant as m', 'c.merchant_id', '=', 'm.id') + ->leftJoin('ma_payment_type as t', 'c.pay_type_id', '=', 't.id') + ->select([ + 'c.id', + 'c.name', + 'c.merchant_id', + 'c.channel_mode', + 'c.pay_type_id', + 'c.plugin_code', + 'c.status', + ]) + ->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no") + ->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name") + ->selectRaw("COALESCE(t.name, '') AS pay_type_name"); + + $ids = $this->normalizeIds($filters['ids'] ?? []); + if (!empty($ids)) { + $query->whereIn('c.id', $ids); + } else { + $query->where('c.status', CommonConstant::STATUS_ENABLED); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('c.name', 'like', '%' . $keyword . '%') + ->orWhere('c.plugin_code', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%'); + }); + } + + if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) { + $query->where('c.pay_type_id', $payTypeId); + } + + if (array_key_exists('merchant_id', $filters) && $filters['merchant_id'] !== '' && $filters['merchant_id'] !== null) { + $query->where('c.merchant_id', (int) $filters['merchant_id']); + } + + if (array_key_exists('channel_mode', $filters) && $filters['channel_mode'] !== '' && $filters['channel_mode'] !== null) { + $query->where('c.channel_mode', (int) $filters['channel_mode']); + } + + $excludeIds = $this->normalizeIds($filters['exclude_ids'] ?? []); + if (!empty($excludeIds)) { + $query->whereNotIn('c.id', $excludeIds); + } + } + + $paginator = $query + ->orderBy('c.sort_no') + ->orderByDesc('c.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + return [ + 'list' => collect($paginator->items()) + ->map(function ($channel): array { + return [ + 'label' => sprintf('%s(%d)', (string) $channel->name, (int) $channel->id), + 'value' => (int) $channel->id, + 'merchant_id' => (int) $channel->merchant_id, + 'merchant_no' => (string) ($channel->merchant_no ?? ''), + 'merchant_name' => (string) ($channel->merchant_name ?? ''), + 'channel_mode' => (int) $channel->channel_mode, + 'pay_type_id' => (int) $channel->pay_type_id, + 'pay_type_name' => (string) ($channel->pay_type_name ?? ''), + 'plugin_code' => (string) $channel->plugin_code, + ]; + }) + ->values() + ->all(), + 'total' => (int) $paginator->total(), + 'page' => (int) $paginator->currentPage(), + 'size' => (int) $paginator->perPage(), + ]; + } + + public function routeOptions(array $filters = []): array + { + $query = $this->paymentChannelRepository->query() + ->from('ma_payment_channel as c') + ->leftJoin('ma_payment_type as t', 'c.pay_type_id', '=', 't.id') + ->where('c.status', CommonConstant::STATUS_ENABLED) + ->select([ + 'c.id', + 'c.name', + 'c.merchant_id', + 'c.channel_mode', + 'c.pay_type_id', + 'c.plugin_code', + ]) + ->selectRaw("COALESCE(t.name, '') AS pay_type_name"); + + if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) { + $query->where('c.pay_type_id', $payTypeId); + } + + if (array_key_exists('merchant_id', $filters) && $filters['merchant_id'] !== '') { + $query->where('c.merchant_id', (int) $filters['merchant_id']); + } + + return $query + ->orderBy('c.sort_no') + ->orderByDesc('c.id') + ->get() + ->map(function (PaymentChannel $channel): array { + return [ + 'label' => sprintf('%s(%d)', (string) $channel->name, (int) $channel->id), + 'value' => (int) $channel->id, + 'merchant_id' => (int) $channel->merchant_id, + 'channel_mode' => (int) $channel->channel_mode, + 'pay_type_id' => (int) $channel->pay_type_id, + 'plugin_code' => (string) $channel->plugin_code, + 'pay_type_name' => (string) ($channel->pay_type_name ?? ''), + ]; + }) + ->values() + ->all(); + } + + public function paginate(array $filters = [], int $page = 1, int $pageSize = 10) + { + $query = $this->paymentChannelRepository->query() + ->from('ma_payment_channel as c') + ->leftJoin('ma_merchant as m', 'c.merchant_id', '=', 'm.id') + ->select([ + 'c.*', + ]) + ->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no") + ->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name"); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('c.name', 'like', '%' . $keyword . '%') + ->orWhere('c.plugin_code', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%'); + }); + } + + if (array_key_exists('merchant_id', $filters) && $filters['merchant_id'] !== '') { + $query->where('c.merchant_id', (int) $filters['merchant_id']); + } + + if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) { + $query->where('c.pay_type_id', $payTypeId); + } + + $pluginCode = trim((string) ($filters['plugin_code'] ?? '')); + if ($pluginCode !== '') { + $query->where('c.plugin_code', 'like', '%' . $pluginCode . '%'); + } + + if (array_key_exists('channel_mode', $filters) && $filters['channel_mode'] !== '') { + $query->where('c.channel_mode', (int) $filters['channel_mode']); + } + + if (array_key_exists('status', $filters) && $filters['status'] !== '') { + $query->where('c.status', (int) $filters['status']); + } + + return $query + ->orderBy('c.sort_no') + ->orderByDesc('c.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + } + + public function findById(int $id): ?PaymentChannel + { + return $this->paymentChannelRepository->find($id); + } + + private function normalizeIds(array|string|int $ids): array + { + if (is_string($ids)) { + $ids = array_filter(array_map('trim', explode(',', $ids))); + } elseif (!is_array($ids)) { + $ids = [$ids]; + } + + return array_values(array_filter(array_map(static fn ($id) => (int) $id, $ids), static fn ($id) => $id > 0)); + } +} diff --git a/app/service/payment/config/PaymentChannelService.php b/app/service/payment/config/PaymentChannelService.php new file mode 100644 index 0000000..ae647b4 --- /dev/null +++ b/app/service/payment/config/PaymentChannelService.php @@ -0,0 +1,58 @@ +queryService->enabledOptions(); + } + + public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array + { + return $this->queryService->searchOptions($filters, $page, $pageSize); + } + + public function routeOptions(array $filters = []): array + { + return $this->queryService->routeOptions($filters); + } + + public function paginate(array $filters = [], int $page = 1, int $pageSize = 10) + { + return $this->queryService->paginate($filters, $page, $pageSize); + } + + public function findById(int $id): ?PaymentChannel + { + return $this->queryService->findById($id); + } + + public function create(array $data): PaymentChannel + { + return $this->commandService->create($data); + } + + public function update(int $id, array $data): ?PaymentChannel + { + return $this->commandService->update($id, $data); + } + + public function delete(int $id): bool + { + return $this->commandService->delete($id); + } +} diff --git a/app/service/payment/config/PaymentPluginConfService.php b/app/service/payment/config/PaymentPluginConfService.php new file mode 100644 index 0000000..9bb2142 --- /dev/null +++ b/app/service/payment/config/PaymentPluginConfService.php @@ -0,0 +1,228 @@ +paymentPluginConfRepository->query() + ->from('ma_payment_plugin_conf as c') + ->leftJoin('ma_payment_plugin as p', 'c.plugin_code', '=', 'p.code') + ->select([ + 'c.id', + 'c.plugin_code', + 'c.config', + 'c.settlement_cycle_type', + 'c.settlement_cutoff_time', + 'c.remark', + 'c.created_at', + 'c.updated_at', + ]) + ->selectRaw("COALESCE(NULLIF(p.name, ''), c.plugin_code) AS plugin_name"); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('c.plugin_code', 'like', '%' . $keyword . '%') + ->orWhere('c.remark', 'like', '%' . $keyword . '%') + ->orWhere('p.name', 'like', '%' . $keyword . '%'); + }); + } + + $pluginCode = trim((string) ($filters['plugin_code'] ?? '')); + if ($pluginCode !== '') { + $query->where('c.plugin_code', $pluginCode); + } + + return $query + ->orderByDesc('c.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + } + + /** + * 按 ID 查询插件配置。 + */ + public function findById(int $id): ?PaymentPluginConf + { + return $this->paymentPluginConfRepository->find($id); + } + + /** + * 新增插件配置。 + */ + public function create(array $data): PaymentPluginConf + { + $payload = $this->normalizePayload($data); + $this->assertPluginExists((string) $payload['plugin_code']); + + return $this->paymentPluginConfRepository->create($payload); + } + + /** + * 修改插件配置。 + */ + public function update(int $id, array $data): ?PaymentPluginConf + { + $payload = $this->normalizePayload($data); + $this->assertPluginExists((string) $payload['plugin_code']); + + if (!$this->paymentPluginConfRepository->updateById($id, $payload)) { + return null; + } + + return $this->paymentPluginConfRepository->find($id); + } + + /** + * 删除插件配置。 + */ + public function delete(int $id): bool + { + return $this->paymentPluginConfRepository->deleteById($id); + } + + /** + * 查询插件配置下拉选项。 + */ + public function options(?string $pluginCode = null): array + { + $pluginCode = trim((string) $pluginCode); + + $query = $this->paymentPluginConfRepository->query() + ->from('ma_payment_plugin_conf as c') + ->leftJoin('ma_payment_plugin as p', 'c.plugin_code', '=', 'p.code') + ->select([ + 'c.id', + 'c.plugin_code', + ]) + ->selectRaw("COALESCE(NULLIF(p.name, ''), c.plugin_code) AS plugin_name") + ->orderByDesc('c.id'); + + if ($pluginCode !== '') { + $query->where('c.plugin_code', $pluginCode); + } + + return $query->get()->map(function ($item): array { + $pluginName = trim((string) ($item->plugin_name ?? '')); + $pluginCode = trim((string) ($item->plugin_code ?? '')); + $label = $pluginName !== '' ? $pluginName : $pluginCode; + + return [ + 'label' => sprintf('%s(%d)', $label, (int) $item->id), + 'value' => (int) $item->id, + 'plugin_code' => $pluginCode, + 'plugin_name' => $pluginName !== '' ? $pluginName : $pluginCode, + ]; + })->values()->all(); + } + + /** + * 远程查询插件配置选择项。 + */ + public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array + { + $query = $this->paymentPluginConfRepository->query() + ->from('ma_payment_plugin_conf as c') + ->leftJoin('ma_payment_plugin as p', 'c.plugin_code', '=', 'p.code') + ->select([ + 'c.id', + 'c.plugin_code', + ]) + ->selectRaw("COALESCE(NULLIF(p.name, ''), c.plugin_code) AS plugin_name") + ->orderByDesc('c.id'); + + $ids = $filters['ids'] ?? []; + if (is_array($ids) && $ids !== []) { + $query->whereIn('c.id', array_map('intval', $ids)); + } else { + $pluginCode = trim((string) ($filters['plugin_code'] ?? '')); + if ($pluginCode !== '') { + $query->where('c.plugin_code', $pluginCode); + } + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('c.plugin_code', 'like', '%' . $keyword . '%') + ->orWhere('p.name', 'like', '%' . $keyword . '%') + ->orWhere('c.remark', 'like', '%' . $keyword . '%'); + + if (ctype_digit($keyword)) { + $builder->orWhere('c.id', (int) $keyword); + } + }); + } + } + + $paginator = $query->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + return [ + 'list' => collect($paginator->items())->map(function ($item): array { + $pluginName = trim((string) ($item->plugin_name ?? '')); + $pluginCode = trim((string) ($item->plugin_code ?? '')); + $label = $pluginName !== '' ? $pluginName : $pluginCode; + + return [ + 'label' => sprintf('%s(%d)', $label, (int) $item->id), + 'value' => (int) $item->id, + 'plugin_code' => $pluginCode, + 'plugin_name' => $pluginName !== '' ? $pluginName : $pluginCode, + ]; + })->values()->all(), + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'size' => $paginator->perPage(), + ]; + } + + /** + * 标准化写入数据。 + */ + private function normalizePayload(array $data): array + { + return [ + 'plugin_code' => trim((string) ($data['plugin_code'] ?? '')), + 'config' => is_array($data['config'] ?? null) ? $data['config'] : [], + 'settlement_cycle_type' => (int) ($data['settlement_cycle_type'] ?? 1), + 'settlement_cutoff_time' => trim((string) ($data['settlement_cutoff_time'] ?? '23:59:59')) ?: '23:59:59', + 'remark' => trim((string) ($data['remark'] ?? '')), + ]; + } + + /** + * 校验插件是否存在。 + */ + private function assertPluginExists(string $pluginCode): void + { + if ($pluginCode === '') { + throw new PaymentException('插件编码不能为空', 40230); + } + + if (!$this->paymentPluginRepository->findByCode($pluginCode)) { + throw new PaymentException('支付插件不存在', 40231, [ + 'plugin_code' => $pluginCode, + ]); + } + } +} diff --git a/app/service/payment/config/PaymentPluginService.php b/app/service/payment/config/PaymentPluginService.php new file mode 100644 index 0000000..5e9acf1 --- /dev/null +++ b/app/service/payment/config/PaymentPluginService.php @@ -0,0 +1,207 @@ +paymentPluginRepository->query(); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('code', 'like', '%' . $keyword . '%') + ->orWhere('name', 'like', '%' . $keyword . '%') + ->orWhere('class_name', 'like', '%' . $keyword . '%'); + }); + } + + $code = trim((string) ($filters['code'] ?? '')); + if ($code !== '') { + $query->where('code', 'like', '%' . $code . '%'); + } + + $name = trim((string) ($filters['name'] ?? '')); + if ($name !== '') { + $query->where('name', 'like', '%' . $name . '%'); + } + + if (array_key_exists('status', $filters) && $filters['status'] !== '') { + $query->where('status', (int) $filters['status']); + } + + return $query + ->orderBy('code') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + } + + /** + * 查询启用中的支付插件选项。 + */ + public function enabledOptions(): array + { + return $this->paymentPluginRepository->enabledList(['code', 'name']) + ->map(function (PaymentPlugin $plugin): array { + return [ + 'label' => sprintf('%s(%s)', (string) $plugin->name, (string) $plugin->code), + 'value' => (string) $plugin->code, + 'code' => (string) $plugin->code, + 'name' => (string) $plugin->name, + ]; + }) + ->values() + ->all(); + } + + /** + * 远程查询支付插件选择项。 + */ + public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array + { + $query = $this->paymentPluginRepository->query() + ->where('status', 1) + ->select(['code', 'name', 'pay_types']) + ->orderBy('code'); + + $ids = $filters['ids'] ?? []; + if (is_array($ids) && $ids !== []) { + $query->whereIn('code', array_values(array_filter(array_map('strval', $ids)))); + } else { + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('code', 'like', '%' . $keyword . '%') + ->orWhere('name', 'like', '%' . $keyword . '%'); + }); + } + + $payTypeCode = trim((string) ($filters['pay_type_code'] ?? '')); + if ($payTypeCode !== '') { + $query->whereJsonContains('pay_types', $payTypeCode); + } + } + + $paginator = $query->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + return [ + 'list' => collect($paginator->items())->map(function (PaymentPlugin $plugin): array { + return [ + 'label' => sprintf('%s(%s)', (string) $plugin->name, (string) $plugin->code), + 'value' => (string) $plugin->code, + 'code' => (string) $plugin->code, + 'name' => (string) $plugin->name, + 'pay_types' => is_array($plugin->pay_types) ? array_values($plugin->pay_types) : [], + ]; + })->values()->all(), + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'size' => $paginator->perPage(), + ]; + } + + /** + * 查询通道配置场景使用的支付插件选项。 + */ + public function channelOptions(): array + { + return $this->paymentPluginRepository->enabledList([ + 'code', + 'name', + 'pay_types', + ]) + ->map(function (PaymentPlugin $plugin): array { + return [ + 'label' => sprintf('%s(%s)', (string) $plugin->name, (string) $plugin->code), + 'value' => (string) $plugin->code, + 'code' => (string) $plugin->code, + 'name' => (string) $plugin->name, + 'pay_types' => is_array($plugin->pay_types) ? array_values($plugin->pay_types) : [], + ]; + }) + ->values() + ->all(); + } + + /** + * 按插件编码查询插件。 + */ + public function findByCode(string $code): ?PaymentPlugin + { + return $this->paymentPluginRepository->findByCode($code); + } + + /** + * 查询插件配置结构。 + * + * @return array + */ + public function getSchema(string $code): array + { + $plugin = $this->paymentPluginRepository->findByCode($code); + if (!$plugin) { + throw new PaymentException('支付插件不存在', 404, [ + 'plugin_code' => $code, + ]); + } + + return [ + 'config_schema' => is_array($plugin->config_schema) ? array_values($plugin->config_schema) : [], + ]; + } + + /** + * 更新支付插件。 + */ + public function update(string $code, array $data): ?PaymentPlugin + { + $payload = []; + if (array_key_exists('status', $data)) { + $payload['status'] = (int) $data['status']; + } + + if (array_key_exists('remark', $data)) { + $payload['remark'] = trim((string) $data['remark']); + } + + if ($payload === []) { + return $this->paymentPluginRepository->findByCode($code); + } + + if (!$this->paymentPluginRepository->updateByKey($code, $payload)) { + return null; + } + + return $this->paymentPluginRepository->findByCode($code); + } + + /** + * 从插件目录刷新并同步支付插件定义。 + */ + public function refreshFromClasses(): array + { + return $this->paymentPluginSyncService->refreshFromClasses(); + } +} diff --git a/app/service/payment/config/PaymentPluginSyncService.php b/app/service/payment/config/PaymentPluginSyncService.php new file mode 100644 index 0000000..0feda03 --- /dev/null +++ b/app/service/payment/config/PaymentPluginSyncService.php @@ -0,0 +1,122 @@ +instantiatePlugin($className); + if (!$plugin) { + continue; + } + + $code = trim((string) $plugin->getCode()); + if ($code === '') { + throw new PaymentException('支付插件编码不能为空', 40220, ['class_name' => $className]); + } + + if (isset($rows[$code])) { + throw new PaymentException('支付插件编码重复', 40221, [ + 'plugin_code' => $code, + 'class_name' => $className, + ]); + } + + $rows[$code] = [ + 'code' => $plugin->getCode(), + 'name' => $plugin->getName(), + 'class_name' => $shortClassName, + 'config_schema' => $plugin->getConfigSchema(), + 'pay_types' => $plugin->getEnabledPayTypes(), + 'transfer_types' => $plugin->getEnabledTransferTypes(), + 'version' => $plugin->getVersion(), + 'author' => $plugin->getAuthorName(), + 'link' => $plugin->getAuthorLink(), + ]; + } + + ksort($rows); + + $existing = $this->paymentPluginRepository->query() + ->get() + ->keyBy('code') + ->all(); + + $this->transaction(function () use ($rows, $existing) { + foreach ($rows as $code => $row) { + /** @var PaymentPlugin|null $current */ + $current = $existing[$code] ?? null; + $payload = array_merge($row, [ + 'status' => (int) ($current->status ?? 1), + 'remark' => (string) ($current->remark ?? ''), + ]); + + if ($current) { + $current->fill($payload); + $current->save(); + unset($existing[$code]); + continue; + } + + $this->paymentPluginRepository->create($payload); + } + + foreach ($existing as $plugin) { + $plugin->delete(); + } + }); + + return [ + 'count' => count($rows), + 'plugins' => $this->paymentPluginRepository->query() + ->orderBy('code') + ->get() + ->values() + ->all(), + ]; + } + + /** + * 实例化插件类并过滤非支付插件类。 + */ + private function instantiatePlugin(string $className): null|(PaymentInterface & PayPluginInterface) + { + if (!class_exists($className)) { + return null; + } + + $instance = container_make($className, []); + if (!$instance instanceof PayPluginInterface || !$instance instanceof PaymentInterface) { + return null; + } + + return $instance; + } +} diff --git a/app/service/payment/config/PaymentPollGroupBindService.php b/app/service/payment/config/PaymentPollGroupBindService.php new file mode 100644 index 0000000..3de8062 --- /dev/null +++ b/app/service/payment/config/PaymentPollGroupBindService.php @@ -0,0 +1,162 @@ +paymentPollGroupBindRepository->query() + ->from('ma_payment_poll_group_bind as b') + ->leftJoin('ma_merchant_group as mg', 'mg.id', '=', 'b.merchant_group_id') + ->leftJoin('ma_payment_type as t', 't.id', '=', 'b.pay_type_id') + ->leftJoin('ma_payment_poll_group as pg', 'pg.id', '=', 'b.poll_group_id') + ->select([ + 'b.id', + 'b.merchant_group_id', + 'b.pay_type_id', + 'b.poll_group_id', + 'b.status', + 'b.remark', + 'b.created_at', + 'b.updated_at', + 'mg.group_name as merchant_group_name', + 't.name as pay_type_name', + 'pg.group_name as poll_group_name', + 'pg.route_mode', + ]); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('mg.group_name', 'like', '%' . $keyword . '%') + ->orWhere('t.name', 'like', '%' . $keyword . '%') + ->orWhere('pg.group_name', 'like', '%' . $keyword . '%'); + }); + } + + if (($merchantGroupId = (int) ($filters['merchant_group_id'] ?? 0)) > 0) { + $query->where('b.merchant_group_id', $merchantGroupId); + } + + if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) { + $query->where('b.pay_type_id', $payTypeId); + } + + if (($pollGroupId = (int) ($filters['poll_group_id'] ?? 0)) > 0) { + $query->where('b.poll_group_id', $pollGroupId); + } + + if (array_key_exists('status', $filters) && $filters['status'] !== '') { + $query->where('b.status', (int) $filters['status']); + } + + return $query + ->orderByDesc('b.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + } + + public function findById(int $id): ?PaymentPollGroupBind + { + return $this->paymentPollGroupBindRepository->find($id); + } + + public function create(array $data): PaymentPollGroupBind + { + $this->assertBindingUnique((int) $data['merchant_group_id'], (int) $data['pay_type_id']); + $this->assertPollGroupMatchesPayType($data); + + return $this->paymentPollGroupBindRepository->create($this->normalizePayload($data)); + } + + public function update(int $id, array $data): ?PaymentPollGroupBind + { + $current = $this->paymentPollGroupBindRepository->find($id); + if (!$current) { + return null; + } + + $merchantGroupId = (int) ($data['merchant_group_id'] ?? $current->merchant_group_id); + $payTypeId = (int) ($data['pay_type_id'] ?? $current->pay_type_id); + $this->assertBindingUnique($merchantGroupId, $payTypeId, $id); + $this->assertPollGroupMatchesPayType(array_merge($current->toArray(), $data)); + + if (!$this->paymentPollGroupBindRepository->updateById($id, $this->normalizePayload($data))) { + return null; + } + + return $this->paymentPollGroupBindRepository->find($id); + } + + public function delete(int $id): bool + { + return $this->paymentPollGroupBindRepository->deleteById($id); + } + + private function normalizePayload(array $data): array + { + return [ + 'merchant_group_id' => (int) $data['merchant_group_id'], + 'pay_type_id' => (int) $data['pay_type_id'], + 'poll_group_id' => (int) $data['poll_group_id'], + 'status' => (int) ($data['status'] ?? 1), + 'remark' => trim((string) ($data['remark'] ?? '')), + ]; + } + + private function assertBindingUnique(int $merchantGroupId, int $payTypeId, int $ignoreId = 0): void + { + $query = $this->paymentPollGroupBindRepository->query() + ->where('merchant_group_id', $merchantGroupId) + ->where('pay_type_id', $payTypeId); + + if ($ignoreId > 0) { + $query->where('id', '<>', $ignoreId); + } + + if ($query->exists()) { + throw new PaymentException('当前商户分组与支付方式已绑定轮询组', 40232, [ + 'merchant_group_id' => $merchantGroupId, + 'pay_type_id' => $payTypeId, + ]); + } + } + + private function assertPollGroupMatchesPayType(array $data): void + { + $pollGroupId = (int) ($data['poll_group_id'] ?? 0); + $payTypeId = (int) ($data['pay_type_id'] ?? 0); + + $pollGroup = $this->paymentPollGroupRepository->find($pollGroupId); + if (!$pollGroup) { + return; + } + + if ((int) $pollGroup->pay_type_id !== $payTypeId) { + throw new PaymentException('轮询组与支付方式不一致', 40233, [ + 'poll_group_id' => $pollGroupId, + 'pay_type_id' => $payTypeId, + ]); + } + } +} diff --git a/app/service/payment/config/PaymentPollGroupChannelService.php b/app/service/payment/config/PaymentPollGroupChannelService.php new file mode 100644 index 0000000..556301f --- /dev/null +++ b/app/service/payment/config/PaymentPollGroupChannelService.php @@ -0,0 +1,184 @@ +paymentPollGroupChannelRepository->query() + ->from('ma_payment_poll_group_channel as pgc') + ->leftJoin('ma_payment_poll_group as pg', 'pg.id', '=', 'pgc.poll_group_id') + ->leftJoin('ma_payment_channel as c', 'c.id', '=', 'pgc.channel_id') + ->leftJoin('ma_payment_type as t', 't.id', '=', 'pg.pay_type_id') + ->select([ + 'pgc.id', + 'pgc.poll_group_id', + 'pgc.channel_id', + 'pgc.sort_no', + 'pgc.weight', + 'pgc.is_default', + 'pgc.status', + 'pgc.remark', + 'pgc.created_at', + 'pgc.updated_at', + 'pg.group_name as poll_group_name', + 'pg.pay_type_id', + 'c.name as channel_name', + 'c.merchant_id', + 'c.channel_mode', + 'c.plugin_code', + 't.name as pay_type_name', + ]); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('pg.group_name', 'like', '%' . $keyword . '%') + ->orWhere('c.name', 'like', '%' . $keyword . '%') + ->orWhere('c.plugin_code', 'like', '%' . $keyword . '%'); + }); + } + + if (($pollGroupId = (int) ($filters['poll_group_id'] ?? 0)) > 0) { + $query->where('pgc.poll_group_id', $pollGroupId); + } + + if (($channelId = (int) ($filters['channel_id'] ?? 0)) > 0) { + $query->where('pgc.channel_id', $channelId); + } + + if (array_key_exists('status', $filters) && $filters['status'] !== '') { + $query->where('pgc.status', (int) $filters['status']); + } + + return $query + ->orderBy('pgc.poll_group_id') + ->orderBy('pgc.sort_no') + ->orderByDesc('pgc.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + } + + public function findById(int $id): ?PaymentPollGroupChannel + { + return $this->paymentPollGroupChannelRepository->find($id); + } + + public function create(array $data): PaymentPollGroupChannel + { + $this->assertPairUnique((int) $data['poll_group_id'], (int) $data['channel_id']); + $this->assertChannelMatchesPollGroup($data); + $payload = $this->normalizePayload($data); + + return $this->transaction(function () use ($payload) { + if ((int) ($payload['is_default'] ?? 0) === 1) { + $this->paymentPollGroupChannelRepository->clearDefaultExcept((int) $payload['poll_group_id']); + } + + return $this->paymentPollGroupChannelRepository->create($payload); + }); + } + + public function update(int $id, array $data): ?PaymentPollGroupChannel + { + $current = $this->paymentPollGroupChannelRepository->find($id); + if (!$current) { + return null; + } + + $pollGroupId = (int) ($data['poll_group_id'] ?? $current->poll_group_id); + $channelId = (int) ($data['channel_id'] ?? $current->channel_id); + $this->assertPairUnique($pollGroupId, $channelId, $id); + $this->assertChannelMatchesPollGroup(array_merge($current->toArray(), $data)); + + $payload = $this->normalizePayload($data); + + return $this->transaction(function () use ($id, $payload) { + if ((int) ($payload['is_default'] ?? 0) === 1) { + $this->paymentPollGroupChannelRepository->clearDefaultExcept((int) $payload['poll_group_id'], $id); + } + + if (!$this->paymentPollGroupChannelRepository->updateById($id, $payload)) { + return null; + } + + return $this->paymentPollGroupChannelRepository->find($id); + }); + } + + public function delete(int $id): bool + { + return $this->paymentPollGroupChannelRepository->deleteById($id); + } + + private function normalizePayload(array $data): array + { + return [ + 'poll_group_id' => (int) $data['poll_group_id'], + 'channel_id' => (int) $data['channel_id'], + 'sort_no' => (int) ($data['sort_no'] ?? 0), + 'weight' => max(1, (int) ($data['weight'] ?? 100)), + 'is_default' => (int) ($data['is_default'] ?? 0), + 'status' => (int) ($data['status'] ?? 1), + 'remark' => trim((string) ($data['remark'] ?? '')), + ]; + } + + private function assertPairUnique(int $pollGroupId, int $channelId, int $ignoreId = 0): void + { + $query = $this->paymentPollGroupChannelRepository->query() + ->where('poll_group_id', $pollGroupId) + ->where('channel_id', $channelId); + + if ($ignoreId > 0) { + $query->where('id', '<>', $ignoreId); + } + + if ($query->exists()) { + throw new PaymentException('该轮询组已添加当前支付通道', 40230, [ + 'poll_group_id' => $pollGroupId, + 'channel_id' => $channelId, + ]); + } + } + + private function assertChannelMatchesPollGroup(array $data): void + { + $pollGroupId = (int) ($data['poll_group_id'] ?? 0); + $channelId = (int) ($data['channel_id'] ?? 0); + + $pollGroup = $this->paymentPollGroupRepository->find($pollGroupId); + $channel = $this->paymentChannelRepository->find($channelId); + + if (!$pollGroup || !$channel) { + return; + } + + if ((int) $pollGroup->pay_type_id !== (int) $channel->pay_type_id) { + throw new PaymentException('轮询组与支付通道的支付方式不一致', 40231, [ + 'poll_group_id' => $pollGroupId, + 'channel_id' => $channelId, + ]); + } + } +} diff --git a/app/service/payment/config/PaymentPollGroupCommandService.php b/app/service/payment/config/PaymentPollGroupCommandService.php new file mode 100644 index 0000000..28c27ba --- /dev/null +++ b/app/service/payment/config/PaymentPollGroupCommandService.php @@ -0,0 +1,55 @@ +assertGroupNameUnique((string) ($data['group_name'] ?? '')); + return $this->paymentPollGroupRepository->create($data); + } + + public function update(int $id, array $data): ?PaymentPollGroup + { + $this->assertGroupNameUnique((string) ($data['group_name'] ?? ''), $id); + if (!$this->paymentPollGroupRepository->updateById($id, $data)) { + return null; + } + + return $this->paymentPollGroupRepository->find($id); + } + + public function delete(int $id): bool + { + return $this->paymentPollGroupRepository->deleteById($id); + } + + private function assertGroupNameUnique(string $groupName, int $ignoreId = 0): void + { + $groupName = trim($groupName); + if ($groupName === '') { + return; + } + + if ($this->paymentPollGroupRepository->existsByGroupName($groupName, $ignoreId)) { + throw new PaymentException('轮询组名称已存在', 40234, [ + 'group_name' => $groupName, + 'ignore_id' => $ignoreId, + ]); + } + } +} diff --git a/app/service/payment/config/PaymentPollGroupQueryService.php b/app/service/payment/config/PaymentPollGroupQueryService.php new file mode 100644 index 0000000..b21f5b5 --- /dev/null +++ b/app/service/payment/config/PaymentPollGroupQueryService.php @@ -0,0 +1,79 @@ +paymentPollGroupRepository->query(); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where('group_name', 'like', '%' . $keyword . '%'); + } + + $groupName = trim((string) ($filters['group_name'] ?? '')); + if ($groupName !== '') { + $query->where('group_name', 'like', '%' . $groupName . '%'); + } + + if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) { + $query->where('pay_type_id', $payTypeId); + } + + if (array_key_exists('route_mode', $filters) && $filters['route_mode'] !== '') { + $query->where('route_mode', (int) $filters['route_mode']); + } + + if (array_key_exists('status', $filters) && $filters['status'] !== '') { + $query->where('status', (int) $filters['status']); + } + + return $query + ->orderByDesc('id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + } + + public function enabledOptions(array $filters = []): array + { + $query = $this->paymentPollGroupRepository->query() + ->where('status', 1); + + if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) { + $query->where('pay_type_id', $payTypeId); + } + + return $query + ->orderBy('group_name') + ->orderByDesc('id') + ->get(['id', 'group_name', 'pay_type_id', 'route_mode']) + ->map(function (PaymentPollGroup $pollGroup): array { + return [ + 'label' => sprintf('%s(%d)', (string) $pollGroup->group_name, (int) $pollGroup->id), + 'value' => (int) $pollGroup->id, + 'pay_type_id' => (int) $pollGroup->pay_type_id, + 'route_mode' => (int) $pollGroup->route_mode, + ]; + }) + ->values() + ->all(); + } + + public function findById(int $id): ?PaymentPollGroup + { + return $this->paymentPollGroupRepository->find($id); + } +} diff --git a/app/service/payment/config/PaymentPollGroupService.php b/app/service/payment/config/PaymentPollGroupService.php new file mode 100644 index 0000000..e5849ed --- /dev/null +++ b/app/service/payment/config/PaymentPollGroupService.php @@ -0,0 +1,48 @@ +queryService->paginate($filters, $page, $pageSize); + } + + public function enabledOptions(array $filters = []): array + { + return $this->queryService->enabledOptions($filters); + } + + public function findById(int $id): ?PaymentPollGroup + { + return $this->queryService->findById($id); + } + + public function create(array $data): PaymentPollGroup + { + return $this->commandService->create($data); + } + + public function update(int $id, array $data): ?PaymentPollGroup + { + return $this->commandService->update($id, $data); + } + + public function delete(int $id): bool + { + return $this->commandService->delete($id); + } +} diff --git a/app/service/payment/config/PaymentTypeService.php b/app/service/payment/config/PaymentTypeService.php new file mode 100644 index 0000000..3f31c52 --- /dev/null +++ b/app/service/payment/config/PaymentTypeService.php @@ -0,0 +1,150 @@ +paymentTypeRepository->query(); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('code', 'like', '%' . $keyword . '%') + ->orWhere('name', 'like', '%' . $keyword . '%'); + }); + } + + $code = trim((string) ($filters['code'] ?? '')); + if ($code !== '') { + $query->where('code', 'like', '%' . $code . '%'); + } + + $name = trim((string) ($filters['name'] ?? '')); + if ($name !== '') { + $query->where('name', 'like', '%' . $name . '%'); + } + + if (array_key_exists('status', $filters) && $filters['status'] !== '') { + $query->where('status', (int) $filters['status']); + } + + return $query + ->orderBy('sort_no') + ->orderByDesc('id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + } + + /** + * 查询启用中的支付方式选项。 + */ + public function enabledOptions(): array + { + return $this->paymentTypeRepository->enabledList(['id', 'code', 'name']) + ->map(function (PaymentType $paymentType): array { + return [ + 'label' => (string) $paymentType->name, + 'value' => (int) $paymentType->id, + 'code' => (string) $paymentType->code, + ]; + }) + ->values() + ->all(); + } + + /** + * 解析启用中的支付方式,优先按编码匹配,未命中则取首个启用项。 + */ + public function resolveEnabledType(string $code = ''): PaymentType + { + $code = trim($code); + if ($code !== '') { + $paymentType = $this->paymentTypeRepository->findByCode($code); + if ($paymentType && (int) $paymentType->status === 1) { + return $paymentType; + } + } + + $paymentType = $this->paymentTypeRepository->enabledList()->first(); + if (!$paymentType) { + throw new ValidationException('未配置可用支付方式'); + } + + return $paymentType; + } + + /** + * 根据支付方式编码查询字典。 + */ + public function findByCode(string $code): ?PaymentType + { + return $this->paymentTypeRepository->findByCode(trim($code)); + } + + /** + * 根据支付方式 ID 解析支付方式编码。 + */ + public function resolveCodeById(int $id): string + { + $paymentType = $this->paymentTypeRepository->find($id); + return $paymentType ? (string) $paymentType->code : ''; + } + + /** + * 按 ID 查询支付方式。 + */ + public function findById(int $id): ?PaymentType + { + return $this->paymentTypeRepository->find($id); + } + + /** + * 新增支付方式。 + */ + public function create(array $data): PaymentType + { + return $this->paymentTypeRepository->create($data); + } + + /** + * 更新支付方式。 + */ + public function update(int $id, array $data): ?PaymentType + { + if (!$this->paymentTypeRepository->updateById($id, $data)) { + return null; + } + + return $this->paymentTypeRepository->find($id); + } + + /** + * 删除支付方式。 + */ + public function delete(int $id): bool + { + return $this->paymentTypeRepository->deleteById($id); + } +} diff --git a/app/service/payment/order/PayOrderAttemptService.php b/app/service/payment/order/PayOrderAttemptService.php new file mode 100644 index 0000000..6ef8228 --- /dev/null +++ b/app/service/payment/order/PayOrderAttemptService.php @@ -0,0 +1,256 @@ +merchantService->ensureMerchantEnabled($merchantId); + $merchantGroupId = (int) $merchant->group_id; + if ($merchantGroupId <= 0) { + throw new ValidationException('商户未配置分组', ['merchant_id' => $merchantId]); + } + $this->merchantService->ensureMerchantGroupEnabled($merchantGroupId); + + /** @var PaymentType|null $paymentType */ + $paymentType = $this->paymentTypeRepository->find($payTypeId); + if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) { + throw new BusinessStateException('支付方式不支持', ['pay_type_id' => $payTypeId]); + } + + $route = $this->paymentRouteService->resolveByMerchantGroup($merchantGroupId, $payTypeId, $payAmount, $input); + $selected = $route['selected_channel']; + /** @var PaymentChannel $channel */ + $channel = $selected['channel']; + + $payNo = $this->generateNo('PAY'); + $channelRequestNo = $this->generateNo('REQ'); + + $prepared = $this->transactionRetry(function () use ( + $input, + $merchant, + $merchantId, + $merchantGroupId, + $merchantOrderNo, + $payTypeId, + $payAmount, + $route, + $channel, + $payNo, + $channelRequestNo + ) { + $existingBizOrder = $this->bizOrderRepository->findForUpdateByMerchantAndOrderNo($merchantId, $merchantOrderNo); + $bizTraceNo = ''; + + if ($existingBizOrder) { + if ((int) $existingBizOrder->order_amount !== $payAmount) { + throw new ValidationException('同一商户订单号金额不一致', [ + 'merchant_id' => $merchantId, + 'merchant_order_no' => $merchantOrderNo, + ]); + } + + if (in_array((int) $existingBizOrder->status, [ + TradeConstant::ORDER_STATUS_SUCCESS, + TradeConstant::ORDER_STATUS_CLOSED, + TradeConstant::ORDER_STATUS_TIMEOUT, + ], true)) { + throw new BusinessStateException('支付单状态不允许重复创建', [ + 'biz_no' => (string) $existingBizOrder->biz_no, + 'status' => (int) $existingBizOrder->status, + ]); + } + + if (!empty($existingBizOrder->active_pay_no)) { + $activePayOrder = $this->payOrderRepository->findForUpdateByPayNo((string) $existingBizOrder->active_pay_no); + if ($activePayOrder && in_array((int) $activePayOrder->status, [TradeConstant::ORDER_STATUS_CREATED, TradeConstant::ORDER_STATUS_PAYING], true)) { + throw new ConflictException('重复请求', [ + 'biz_no' => (string) $existingBizOrder->biz_no, + 'active_pay_no' => (string) $existingBizOrder->active_pay_no, + ]); + } + } + + $bizOrder = $existingBizOrder; + $bizTraceNo = trim((string) ($bizOrder->trace_no ?? '')); + if ($bizTraceNo === '') { + $bizTraceNo = (string) $bizOrder->biz_no; + $bizOrder->trace_no = $bizTraceNo; + } + $attemptNo = (int) $bizOrder->attempt_count + 1; + } else { + $bizOrder = $this->bizOrderRepository->create([ + 'biz_no' => $this->generateNo('BIZ'), + 'trace_no' => $this->generateNo('TRC'), + 'merchant_id' => $merchantId, + 'merchant_group_id' => $merchantGroupId, + 'poll_group_id' => (int) $route['poll_group']->id, + 'merchant_order_no' => $merchantOrderNo, + 'subject' => (string) ($input['subject'] ?? ''), + 'body' => (string) ($input['body'] ?? ''), + 'order_amount' => $payAmount, + 'paid_amount' => 0, + 'refund_amount' => 0, + 'status' => TradeConstant::ORDER_STATUS_CREATED, + 'attempt_count' => 0, + 'ext_json' => $input['ext_json'] ?? [], + ]); + $bizTraceNo = (string) $bizOrder->trace_no; + $attemptNo = 1; + } + + $feeRateBp = (int) $channel->cost_rate_bp; + $splitRateBp = (int) $channel->split_rate_bp ?: 10000; + $feeEstimated = $this->calculateAmountByBp($payAmount, $feeRateBp); + + if ((int) $channel->channel_mode === RouteConstant::CHANNEL_MODE_SELF && $feeEstimated > 0) { + $this->merchantAccountService->freezeAmountInCurrentTransaction( + $merchantId, + $feeEstimated, + $payNo, + 'PAY_FREEZE:' . $payNo, + [ + 'merchant_order_no' => $merchantOrderNo, + 'pay_type_id' => $payTypeId, + 'channel_id' => (int) $channel->id, + 'remark' => '自有通道手续费预占', + ], + $bizTraceNo + ); + } + + $payOrder = $this->payOrderRepository->create([ + 'pay_no' => $payNo, + 'biz_no' => (string) $bizOrder->biz_no, + 'trace_no' => $bizTraceNo, + 'merchant_id' => $merchantId, + 'merchant_group_id' => $merchantGroupId, + 'poll_group_id' => (int) $route['poll_group']->id, + 'attempt_no' => (int) $attemptNo, + 'channel_id' => (int) $channel->id, + 'pay_type_id' => $payTypeId, + 'plugin_code' => (string) $channel->plugin_code, + 'channel_type' => (int) $channel->channel_mode, + 'channel_mode' => (int) $channel->channel_mode, + 'pay_amount' => $payAmount, + 'fee_rate_bp_snapshot' => $feeRateBp, + 'split_rate_bp_snapshot' => $splitRateBp, + 'fee_estimated_amount' => $feeEstimated, + 'fee_actual_amount' => 0, + 'status' => TradeConstant::ORDER_STATUS_PAYING, + 'fee_status' => (int) $channel->channel_mode === RouteConstant::CHANNEL_MODE_SELF ? TradeConstant::FEE_STATUS_FROZEN : TradeConstant::FEE_STATUS_NONE, + 'settlement_status' => TradeConstant::SETTLEMENT_STATUS_NONE, + 'channel_request_no' => $channelRequestNo, + 'request_at' => $this->now(), + 'callback_status' => NotifyConstant::PROCESS_STATUS_PENDING, + 'callback_times' => 0, + 'ext_json' => array_merge($input['ext_json'] ?? [], [ + 'merchant_no' => (string) $merchant->merchant_no, + 'merchant_group_id' => $merchantGroupId, + 'poll_group_id' => (int) $route['poll_group']->id, + 'channel_id' => (int) $channel->id, + 'channel_mode' => (int) $channel->channel_mode, + 'trace_no' => $bizTraceNo, + ]), + ]); + + $bizOrder->active_pay_no = (string) $payOrder->pay_no; + $bizOrder->attempt_count = (int) $attemptNo; + $bizOrder->status = TradeConstant::ORDER_STATUS_PAYING; + $bizOrder->merchant_group_id = $merchantGroupId; + $bizOrder->poll_group_id = (int) $route['poll_group']->id; + if ($bizTraceNo !== '' && (string) ($bizOrder->trace_no ?? '') === '') { + $bizOrder->trace_no = $bizTraceNo; + } + $bizOrder->save(); + + return [ + 'merchant' => $merchant, + 'biz_order' => $bizOrder->refresh(), + 'pay_order' => $payOrder, + 'route' => $route, + ]; + }); + + /** @var PayOrder $payOrder */ + $payOrder = $prepared['pay_order']; + /** @var BizOrder $bizOrder */ + $bizOrder = $prepared['biz_order']; + /** @var \app\model\payment\PaymentChannel $channel */ + $channel = $prepared['route']['selected_channel']['channel']; + + $channelDispatchResult = $this->payOrderChannelDispatchService->dispatch($payOrder, $bizOrder, $channel); + + $prepared['pay_order'] = $channelDispatchResult['pay_order']; + $prepared['payment_result'] = $channelDispatchResult['payment_result']; + $prepared['pay_params'] = $channelDispatchResult['pay_params']; + + return $prepared; + } + + /** + * 计算手续费金额。 + */ + private function calculateAmountByBp(int $amount, int $bp): int + { + if ($amount <= 0 || $bp <= 0) { + return 0; + } + + return (int) floor($amount * $bp / 10000); + } +} diff --git a/app/service/payment/order/PayOrderCallbackService.php b/app/service/payment/order/PayOrderCallbackService.php new file mode 100644 index 0000000..826e1fb --- /dev/null +++ b/app/service/payment/order/PayOrderCallbackService.php @@ -0,0 +1,139 @@ +notifyService->recordPayCallback([ + 'pay_no' => $payNo, + 'channel_id' => (int) ($input['channel_id'] ?? 0), + 'callback_type' => (int) ($input['callback_type'] ?? NotifyConstant::CALLBACK_TYPE_ASYNC), + 'request_data' => $input['request_data'] ?? [], + 'verify_status' => (int) ($input['verify_status'] ?? NotifyConstant::VERIFY_STATUS_UNKNOWN), + 'process_status' => (int) ($input['process_status'] ?? NotifyConstant::PROCESS_STATUS_PENDING), + 'process_result' => $input['process_result'] ?? [], + ]); + + $success = (bool) ($input['success'] ?? false); + if ($success) { + return $this->payOrderLifecycleService->markPaySuccess($payNo, $input); + } + + return $this->payOrderLifecycleService->markPayFailed($payNo, $input); + } + + /** + * 按支付单号处理真实第三方回调。 + */ + public function handlePluginCallback(string $payNo, Request $request): string|Response + { + $payOrder = $this->payOrderRepository->findByPayNo($payNo); + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + $plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true); + + try { + $result = $plugin->notify($request); + $status = (string) ($result['status'] ?? ''); + $success = array_key_exists('success', $result) + ? (bool) $result['success'] + : in_array($status, ['success', 'paid'], true); + + $callbackPayload = [ + 'pay_no' => $payNo, + 'success' => $success, + 'channel_id' => (int) $payOrder->channel_id, + 'callback_type' => NotifyConstant::CALLBACK_TYPE_ASYNC, + 'request_data' => array_merge($request->get(), $request->post()), + 'verify_status' => NotifyConstant::VERIFY_STATUS_SUCCESS, + 'process_status' => $success ? NotifyConstant::PROCESS_STATUS_SUCCESS : NotifyConstant::PROCESS_STATUS_FAILED, + 'process_result' => $result, + 'channel_trade_no' => (string) ($result['chan_trade_no'] ?? ''), + 'channel_order_no' => (string) ($result['chan_order_no'] ?? ''), + 'paid_at' => $result['paid_at'] ?? null, + 'channel_error_code' => (string) ($result['channel_error_code'] ?? ''), + 'channel_error_msg' => (string) ($result['channel_error_msg'] ?? ''), + 'ext_json' => [ + 'plugin_code' => (string) $payOrder->plugin_code, + 'notify_status' => $status, + ], + ]; + if (isset($result['fee_actual_amount'])) { + $callbackPayload['fee_actual_amount'] = (int) $result['fee_actual_amount']; + } + + $this->handleChannelCallback($callbackPayload); + + return $success ? $plugin->notifySuccess() : $plugin->notifyFail(); + } catch (PaymentException $e) { + $this->notifyService->recordPayCallback([ + 'pay_no' => $payNo, + 'channel_id' => (int) $payOrder->channel_id, + 'callback_type' => NotifyConstant::CALLBACK_TYPE_ASYNC, + 'request_data' => array_merge($request->get(), $request->post()), + 'verify_status' => NotifyConstant::VERIFY_STATUS_FAILED, + 'process_status' => NotifyConstant::PROCESS_STATUS_FAILED, + 'process_result' => [ + 'message' => $e->getMessage(), + 'code' => $e->getCode(), + ], + ]); + + return $plugin->notifyFail(); + } catch (\Throwable $e) { + $this->notifyService->recordPayCallback([ + 'pay_no' => $payNo, + 'channel_id' => (int) $payOrder->channel_id, + 'callback_type' => NotifyConstant::CALLBACK_TYPE_ASYNC, + 'request_data' => array_merge($request->get(), $request->post()), + 'verify_status' => NotifyConstant::VERIFY_STATUS_FAILED, + 'process_status' => NotifyConstant::PROCESS_STATUS_FAILED, + 'process_result' => [ + 'message' => $e->getMessage(), + 'code' => 'PLUGIN_NOTIFY_ERROR', + ], + ]); + + return $plugin->notifyFail(); + } + } +} diff --git a/app/service/payment/order/PayOrderChannelDispatchService.php b/app/service/payment/order/PayOrderChannelDispatchService.php new file mode 100644 index 0000000..2ec81c1 --- /dev/null +++ b/app/service/payment/order/PayOrderChannelDispatchService.php @@ -0,0 +1,134 @@ +paymentPluginManager->createByChannel($channel, (int) $payOrder->pay_type_id); + /** @var PaymentType|null $paymentType */ + $paymentType = $this->paymentTypeRepository->find((int) $payOrder->pay_type_id); + $extJson = (array) ($payOrder->ext_json ?? []); + $callbackBaseUrl = trim((string) ($extJson['channel_callback_base_url'] ?? '')); + $callbackUrl = $callbackBaseUrl === '' + ? '' + : rtrim($callbackBaseUrl, '/') . '/' . $payOrder->pay_no . '/callback'; + + $channelResult = $plugin->pay([ + 'pay_no' => (string) $payOrder->pay_no, + 'order_id' => (string) $payOrder->pay_no, + 'biz_no' => (string) $payOrder->biz_no, + 'trace_no' => (string) $payOrder->trace_no, + 'channel_request_no' => (string) $payOrder->channel_request_no, + 'merchant_id' => (int) $payOrder->merchant_id, + 'merchant_no' => (string) ($extJson['merchant_no'] ?? ''), + 'pay_type_id' => (int) $payOrder->pay_type_id, + 'pay_type_code' => (string) ($paymentType->code ?? ''), + 'amount' => (int) $payOrder->pay_amount, + 'subject' => (string) ($bizOrder->subject ?? ''), + 'body' => (string) ($bizOrder->body ?? ''), + 'callback_url' => $callbackUrl, + 'return_url' => (string) ($extJson['return_url'] ?? ''), + '_env' => (string) (($extJson['device'] ?? '') ?: 'pc'), + 'extra' => $extJson, + ]); + + $payOrder = $this->transactionRetry(function () use ($payOrder, $channelResult) { + $latest = $this->payOrderRepository->findForUpdateByPayNo((string) $payOrder->pay_no); + if (!$latest) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => (string) $payOrder->pay_no]); + } + + $latest->channel_order_no = (string) ($channelResult['chan_order_no'] ?? $latest->channel_order_no ?? ''); + $latest->channel_trade_no = (string) ($channelResult['chan_trade_no'] ?? $latest->channel_trade_no ?? ''); + $latest->ext_json = array_merge((array) $latest->ext_json, [ + 'pay_params_type' => (string) (($channelResult['pay_params']['type'] ?? '') ?: ''), + 'pay_product' => (string) ($channelResult['pay_product'] ?? ''), + 'pay_action' => (string) ($channelResult['pay_action'] ?? ''), + 'pay_params_snapshot' => $this->normalizePayParamsSnapshot($channelResult['pay_params'] ?? []), + ]); + $latest->save(); + + return $latest->refresh(); + }); + } catch (PaymentException $e) { + $this->payOrderLifecycleService->markPayFailed((string) $payOrder->pay_no, [ + 'channel_error_msg' => $e->getMessage(), + 'channel_error_code' => (string) $e->getCode(), + 'ext_json' => [ + 'plugin_code' => (string) $payOrder->plugin_code, + ], + ]); + + throw $e; + } catch (Throwable $e) { + $this->payOrderLifecycleService->markPayFailed((string) $payOrder->pay_no, [ + 'channel_error_msg' => $e->getMessage(), + 'channel_error_code' => 'PLUGIN_CREATE_ORDER_ERROR', + 'ext_json' => [ + 'plugin_code' => (string) $payOrder->plugin_code, + ], + ]); + + throw new PaymentException('创建第三方支付订单失败:' . $e->getMessage(), 40215); + } + + return [ + 'pay_order' => $payOrder, + 'payment_result' => $channelResult, + 'pay_params' => $channelResult['pay_params'] ?? [], + ]; + } + + /** + * 归一化支付参数快照,便于后续页面渲染和排障。 + */ + private function normalizePayParamsSnapshot(mixed $payParams): array + { + if (is_array($payParams)) { + return $payParams; + } + + if (is_object($payParams) && method_exists($payParams, 'toArray')) { + $data = $payParams->toArray(); + return is_array($data) ? $data : []; + } + + return []; + } +} diff --git a/app/service/payment/order/PayOrderFeeService.php b/app/service/payment/order/PayOrderFeeService.php new file mode 100644 index 0000000..5675306 --- /dev/null +++ b/app/service/payment/order/PayOrderFeeService.php @@ -0,0 +1,140 @@ +channel_type !== RouteConstant::CHANNEL_MODE_SELF) { + return; + } + + $estimated = (int) $payOrder->fee_estimated_amount; + if ($actualFee > $estimated) { + if ($estimated > 0) { + $this->merchantAccountService->deductFrozenAmountInCurrentTransaction( + (int) $payOrder->merchant_id, + $estimated, + $payNo, + 'PAY_DEDUCT:' . $payNo, + [ + 'pay_no' => $payNo, + 'remark' => '自有通道手续费扣减', + ], + $traceNo + ); + } + + $diff = $actualFee - $estimated; + if ($diff > 0) { + $this->merchantAccountService->debitAvailableAmountInCurrentTransaction( + (int) $payOrder->merchant_id, + $diff, + $payNo, + 'PAY_DEDUCT_DIFF:' . $payNo, + [ + 'pay_no' => $payNo, + 'remark' => '自有通道手续费差额扣减', + ], + $traceNo + ); + } + return; + } + + if ($actualFee < $estimated) { + if ($actualFee > 0) { + $this->merchantAccountService->deductFrozenAmountInCurrentTransaction( + (int) $payOrder->merchant_id, + $actualFee, + $payNo, + 'PAY_DEDUCT:' . $payNo, + [ + 'pay_no' => $payNo, + 'remark' => '自有通道手续费扣减', + ], + $traceNo + ); + } + + $diff = $estimated - $actualFee; + if ($diff > 0) { + $this->merchantAccountService->releaseFrozenAmountInCurrentTransaction( + (int) $payOrder->merchant_id, + $diff, + $payNo, + 'PAY_RELEASE:' . $payNo, + [ + 'pay_no' => $payNo, + 'remark' => '自有通道手续费释放差额', + ], + $traceNo + ); + } + return; + } + + if ($actualFee > 0) { + $this->merchantAccountService->deductFrozenAmountInCurrentTransaction( + (int) $payOrder->merchant_id, + $actualFee, + $payNo, + 'PAY_DEDUCT:' . $payNo, + [ + 'pay_no' => $payNo, + 'remark' => '自有通道手续费扣减', + ], + $traceNo + ); + } + } + + /** + * 释放支付单已冻结的手续费。 + */ + public function releaseFrozenFeeIfNeeded(PayOrder $payOrder, string $payNo, string $traceNo, string $remark): void + { + if ((int) $payOrder->channel_type !== RouteConstant::CHANNEL_MODE_SELF) { + return; + } + + if ((int) $payOrder->fee_status !== TradeConstant::FEE_STATUS_FROZEN) { + return; + } + + $this->merchantAccountService->releaseFrozenAmountInCurrentTransaction( + (int) $payOrder->merchant_id, + (int) $payOrder->fee_estimated_amount, + $payNo, + 'PAY_RELEASE:' . $payNo, + [ + 'pay_no' => $payNo, + 'remark' => $remark, + ], + $traceNo + ); + } +} diff --git a/app/service/payment/order/PayOrderLifecycleService.php b/app/service/payment/order/PayOrderLifecycleService.php new file mode 100644 index 0000000..68700cd --- /dev/null +++ b/app/service/payment/order/PayOrderLifecycleService.php @@ -0,0 +1,322 @@ +transactionRetry(function () use ($payNo, $input) { + return $this->markPaySuccessInCurrentTransaction($payNo, $input); + }); + } + + /** + * 在当前事务中标记支付成功。 + * + * 该方法只处理状态推进和资金动作,不负责外部通道请求。 + * + * @param string $payNo 支付单号 + * @param array $input 回调或查单入参 + * @return PayOrder + */ + public function markPaySuccessInCurrentTransaction(string $payNo, array $input = []): PayOrder + { + $payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo); + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + $currentStatus = (int) $payOrder->status; + if ($currentStatus === TradeConstant::ORDER_STATUS_SUCCESS) { + return $payOrder; + } + + if (TradeConstant::isOrderTerminalStatus($currentStatus)) { + return $payOrder; + } + + if (!in_array($currentStatus, TradeConstant::orderMutableStatuses(), true)) { + throw new BusinessStateException('支付单状态不允许当前操作', [ + 'pay_no' => $payNo, + 'status' => $currentStatus, + ]); + } + + $actualFee = array_key_exists('fee_actual_amount', $input) + ? (int) $input['fee_actual_amount'] + : (int) $payOrder->fee_estimated_amount; + $traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no); + + $this->payOrderFeeService->settleSuccessFee($payOrder, $actualFee, $payNo, $traceNo); + + $payOrder->status = TradeConstant::ORDER_STATUS_SUCCESS; + $payOrder->paid_at = $input['paid_at'] ?? $this->now(); + $payOrder->fee_actual_amount = $actualFee; + $payOrder->fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF + ? TradeConstant::FEE_STATUS_DEDUCTED + : TradeConstant::FEE_STATUS_NONE; + $payOrder->settlement_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_COLLECT + ? TradeConstant::SETTLEMENT_STATUS_PENDING + : TradeConstant::SETTLEMENT_STATUS_NONE; + $payOrder->callback_status = NotifyConstant::PROCESS_STATUS_SUCCESS; + $payOrder->channel_trade_no = (string) ($input['channel_trade_no'] ?? $payOrder->channel_trade_no ?? ''); + $payOrder->channel_order_no = (string) ($input['channel_order_no'] ?? $payOrder->channel_order_no ?? ''); + $payOrder->channel_error_code = ''; + $payOrder->channel_error_msg = ''; + $payOrder->callback_times = (int) $payOrder->callback_times + 1; + $payOrder->ext_json = array_merge((array) $payOrder->ext_json, $input['ext_json'] ?? []); + $payOrder->save(); + + $this->syncBizOrderAfterSuccess($payOrder, $traceNo); + + return $payOrder->refresh(); + } + + /** + * 标记支付失败。 + */ + public function markPayFailed(string $payNo, array $input = []): PayOrder + { + return $this->transactionRetry(function () use ($payNo, $input) { + return $this->markPayFailedInCurrentTransaction($payNo, $input); + }); + } + + /** + * 在当前事务中标记支付失败。 + */ + public function markPayFailedInCurrentTransaction(string $payNo, array $input = []): PayOrder + { + $payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo); + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + $currentStatus = (int) $payOrder->status; + if ($currentStatus === TradeConstant::ORDER_STATUS_FAILED) { + return $payOrder; + } + + if (TradeConstant::isOrderTerminalStatus($currentStatus)) { + return $payOrder; + } + + if (!in_array($currentStatus, TradeConstant::orderMutableStatuses(), true)) { + throw new BusinessStateException('支付单状态不允许当前操作', [ + 'pay_no' => $payNo, + 'status' => $currentStatus, + ]); + } + + $traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no); + $this->payOrderFeeService->releaseFrozenFeeIfNeeded($payOrder, $payNo, $traceNo, '支付失败释放手续费'); + + $payOrder->status = TradeConstant::ORDER_STATUS_FAILED; + $payOrder->fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF + ? TradeConstant::FEE_STATUS_RELEASED + : TradeConstant::FEE_STATUS_NONE; + $payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_NONE; + $payOrder->callback_status = NotifyConstant::PROCESS_STATUS_FAILED; + $payOrder->channel_error_code = (string) ($input['channel_error_code'] ?? $payOrder->channel_error_code ?? ''); + $payOrder->channel_error_msg = (string) ($input['channel_error_msg'] ?? $payOrder->channel_error_msg ?? '支付失败'); + $payOrder->failed_at = $input['failed_at'] ?? $this->now(); + $payOrder->callback_times = (int) $payOrder->callback_times + 1; + $payOrder->ext_json = array_merge((array) $payOrder->ext_json, $input['ext_json'] ?? []); + $payOrder->save(); + + $this->syncBizOrderAfterTerminalStatus($payOrder, $payNo, $traceNo, TradeConstant::ORDER_STATUS_FAILED, 'failed_at'); + + return $payOrder->refresh(); + } + + /** + * 关闭支付单。 + */ + public function closePayOrder(string $payNo, array $input = []): PayOrder + { + return $this->transactionRetry(function () use ($payNo, $input) { + return $this->closePayOrderInCurrentTransaction($payNo, $input); + }); + } + + /** + * 在当前事务中关闭支付单。 + */ + public function closePayOrderInCurrentTransaction(string $payNo, array $input = []): PayOrder + { + $payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo); + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + $currentStatus = (int) $payOrder->status; + if ($currentStatus === TradeConstant::ORDER_STATUS_CLOSED) { + return $payOrder; + } + + if (TradeConstant::isOrderTerminalStatus($currentStatus)) { + return $payOrder; + } + + if (!in_array($currentStatus, TradeConstant::orderMutableStatuses(), true)) { + throw new BusinessStateException('支付单状态不允许当前操作', [ + 'pay_no' => $payNo, + 'status' => $currentStatus, + ]); + } + + $traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no); + $this->payOrderFeeService->releaseFrozenFeeIfNeeded($payOrder, $payNo, $traceNo, '支付关闭释放手续费'); + + $payOrder->status = TradeConstant::ORDER_STATUS_CLOSED; + $payOrder->fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF + ? TradeConstant::FEE_STATUS_RELEASED + : TradeConstant::FEE_STATUS_NONE; + $payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_NONE; + $payOrder->closed_at = $input['closed_at'] ?? $this->now(); + $extJson = (array) $payOrder->ext_json; + $reason = trim((string) ($input['reason'] ?? '')); + if ($reason !== '') { + $extJson['close_reason'] = $reason; + } + $payOrder->ext_json = array_merge($extJson, $input['ext_json'] ?? []); + $payOrder->save(); + + $this->syncBizOrderAfterTerminalStatus($payOrder, $payNo, $traceNo, TradeConstant::ORDER_STATUS_CLOSED, 'closed_at'); + + return $payOrder->refresh(); + } + + /** + * 标记支付超时。 + */ + public function timeoutPayOrder(string $payNo, array $input = []): PayOrder + { + return $this->transactionRetry(function () use ($payNo, $input) { + return $this->timeoutPayOrderInCurrentTransaction($payNo, $input); + }); + } + + /** + * 在当前事务中标记支付超时。 + */ + public function timeoutPayOrderInCurrentTransaction(string $payNo, array $input = []): PayOrder + { + $payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo); + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + $currentStatus = (int) $payOrder->status; + if ($currentStatus === TradeConstant::ORDER_STATUS_TIMEOUT) { + return $payOrder; + } + + if (TradeConstant::isOrderTerminalStatus($currentStatus)) { + return $payOrder; + } + + if (!in_array($currentStatus, TradeConstant::orderMutableStatuses(), true)) { + throw new BusinessStateException('支付单状态不允许当前操作', [ + 'pay_no' => $payNo, + 'status' => $currentStatus, + ]); + } + + $traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no); + $this->payOrderFeeService->releaseFrozenFeeIfNeeded($payOrder, $payNo, $traceNo, '支付超时释放手续费'); + + $payOrder->status = TradeConstant::ORDER_STATUS_TIMEOUT; + $payOrder->fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF + ? TradeConstant::FEE_STATUS_RELEASED + : TradeConstant::FEE_STATUS_NONE; + $payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_NONE; + $payOrder->timeout_at = $input['timeout_at'] ?? $this->now(); + $extJson = (array) $payOrder->ext_json; + $reason = trim((string) ($input['reason'] ?? '')); + if ($reason !== '') { + $extJson['timeout_reason'] = $reason; + } + $payOrder->ext_json = array_merge($extJson, $input['ext_json'] ?? []); + $payOrder->save(); + + $this->syncBizOrderAfterTerminalStatus($payOrder, $payNo, $traceNo, TradeConstant::ORDER_STATUS_TIMEOUT, 'timeout_at'); + + return $payOrder->refresh(); + } + + /** + * 同步支付成功后的业务单状态。 + */ + private function syncBizOrderAfterSuccess(PayOrder $payOrder, string $traceNo): void + { + $bizOrder = $this->bizOrderRepository->findForUpdateByBizNo((string) $payOrder->biz_no); + if (!$bizOrder) { + return; + } + + $bizOrder->status = TradeConstant::ORDER_STATUS_SUCCESS; + $bizOrder->paid_amount = (int) $bizOrder->paid_amount + (int) $payOrder->pay_amount; + $bizOrder->active_pay_no = null; + $bizOrder->paid_at = $payOrder->paid_at; + if (empty($bizOrder->trace_no)) { + $bizOrder->trace_no = $traceNo; + } + $bizOrder->save(); + } + + /** + * 同步支付终态后的业务单状态。 + */ + private function syncBizOrderAfterTerminalStatus(PayOrder $payOrder, string $payNo, string $traceNo, int $status, string $timestampField): void + { + $bizOrder = $this->bizOrderRepository->findForUpdateByBizNo((string) $payOrder->biz_no); + if (!$bizOrder || (string) $bizOrder->active_pay_no !== $payNo) { + return; + } + + $bizOrder->status = $status; + $bizOrder->active_pay_no = null; + $bizOrder->{$timestampField} = $payOrder->{$timestampField}; + if (empty($bizOrder->trace_no)) { + $bizOrder->trace_no = $traceNo; + } + $bizOrder->save(); + } + +} diff --git a/app/service/payment/order/PayOrderQueryService.php b/app/service/payment/order/PayOrderQueryService.php new file mode 100644 index 0000000..d74ce3d --- /dev/null +++ b/app/service/payment/order/PayOrderQueryService.php @@ -0,0 +1,253 @@ +payOrderRepository->query() + ->from('ma_pay_order as po') + ->leftJoin('ma_biz_order as bo', 'bo.biz_no', '=', 'po.biz_no') + ->leftJoin('ma_merchant as m', 'm.id', '=', 'po.merchant_id') + ->leftJoin('ma_merchant_group as g', 'g.id', '=', 'po.merchant_group_id') + ->leftJoin('ma_payment_channel as c', 'c.id', '=', 'po.channel_id') + ->leftJoin('ma_payment_type as t', 't.id', '=', 'po.pay_type_id') + ->select([ + 'po.id', + 'po.pay_no', + 'po.biz_no', + 'po.trace_no', + 'po.merchant_id', + 'po.merchant_group_id', + 'po.poll_group_id', + 'po.attempt_no', + 'po.channel_id', + 'po.pay_type_id', + 'po.plugin_code', + 'po.channel_type', + 'po.channel_mode', + 'po.pay_amount', + 'po.fee_rate_bp_snapshot', + 'po.split_rate_bp_snapshot', + 'po.fee_estimated_amount', + 'po.fee_actual_amount', + 'po.status', + 'po.fee_status', + 'po.settlement_status', + 'po.channel_request_no', + 'po.channel_order_no', + 'po.channel_trade_no', + 'po.channel_error_code', + 'po.channel_error_msg', + 'po.request_at', + 'po.paid_at', + 'po.expire_at', + 'po.closed_at', + 'po.failed_at', + 'po.timeout_at', + 'po.callback_status', + 'po.callback_times', + 'po.ext_json', + 'po.created_at', + 'po.updated_at', + 'bo.merchant_order_no', + 'bo.subject', + 'bo.body', + 'bo.order_amount as biz_order_amount', + 'bo.paid_amount as biz_paid_amount', + 'bo.refund_amount as biz_refund_amount', + 'bo.status as biz_status', + 'bo.active_pay_no', + 'bo.attempt_count as biz_attempt_count', + 'bo.expire_at as biz_expire_at', + 'bo.paid_at as biz_paid_at', + 'bo.closed_at as biz_closed_at', + 'bo.failed_at as biz_failed_at', + 'bo.timeout_at as biz_timeout_at', + 'bo.ext_json as biz_ext_json', + 'm.merchant_no', + 'm.merchant_name', + 'm.merchant_short_name', + 'g.group_name as merchant_group_name', + 'c.name as channel_name', + 'c.plugin_code as channel_plugin_code', + 't.code as pay_type_code', + 't.name as pay_type_name', + 't.icon as pay_type_icon', + ]); + + if ($merchantId !== null && $merchantId > 0) { + $query->where('po.merchant_id', $merchantId); + } + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('po.pay_no', 'like', '%' . $keyword . '%') + ->orWhere('po.biz_no', 'like', '%' . $keyword . '%') + ->orWhere('po.trace_no', 'like', '%' . $keyword . '%') + ->orWhere('po.channel_request_no', 'like', '%' . $keyword . '%') + ->orWhere('po.channel_order_no', 'like', '%' . $keyword . '%') + ->orWhere('po.channel_trade_no', 'like', '%' . $keyword . '%') + ->orWhere('bo.merchant_order_no', 'like', '%' . $keyword . '%') + ->orWhere('bo.subject', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%') + ->orWhere('g.group_name', 'like', '%' . $keyword . '%') + ->orWhere('c.name', 'like', '%' . $keyword . '%') + ->orWhere('t.name', 'like', '%' . $keyword . '%'); + }); + } + + if (($merchantFilter = (int) ($filters['merchant_id'] ?? 0)) > 0) { + $query->where('po.merchant_id', $merchantFilter); + } + + if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) { + $query->where('po.pay_type_id', $payTypeId); + } + + if (array_key_exists('status', $filters) && $filters['status'] !== '') { + $query->where('po.status', (int) $filters['status']); + } + + if (array_key_exists('channel_mode', $filters) && $filters['channel_mode'] !== '') { + $query->where('po.channel_mode', (int) $filters['channel_mode']); + } + + if (array_key_exists('callback_status', $filters) && $filters['callback_status'] !== '') { + $query->where('po.callback_status', (int) $filters['callback_status']); + } + + $paginator = $query + ->orderByDesc('po.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + $list = []; + foreach ($paginator->items() as $item) { + $list[] = $this->payOrderReportService->formatPayOrderRow((array) $item); + } + + return [ + 'list' => $list, + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'size' => $paginator->perPage(), + 'pay_types' => $this->payTypeOptions(), + ]; + } + + /** + * 查询支付订单详情。 + * + * @param string $payNo 支付单号 + * @param int|null $merchantId 商户侧强制限定的商户 ID + * @return array{pay_order:mixed,biz_order:mixed,timeline:array,account_ledgers:mixed} + */ + public function detail(string $payNo, ?int $merchantId = null): array + { + $payNo = trim($payNo); + if ($payNo === '') { + throw new ValidationException('pay_no 不能为空'); + } + + $payOrder = $this->payOrderRepository->findByPayNo($payNo); + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + if ($merchantId !== null && $merchantId > 0 && (int) $payOrder->merchant_id !== $merchantId) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + $bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + $timeline = $this->payOrderReportService->buildPayTimeline($payOrder); + $accountLedgers = $this->loadPayLedgers($payOrder); + + return [ + 'pay_order' => $payOrder, + 'biz_order' => $bizOrder, + 'timeline' => $timeline, + 'account_ledgers' => $accountLedgers, + ]; + } + + /** + * 加载支付相关资金流水。 + */ + private function loadPayLedgers(PayOrder $payOrder) + { + $traceNo = trim((string) ($payOrder->trace_no ?: $payOrder->biz_no)); + $ledgers = $traceNo !== '' + ? $this->merchantAccountLedgerRepository->listByTraceNo($traceNo) + : collect(); + + if ($ledgers->isEmpty()) { + $ledgers = $this->merchantAccountLedgerRepository->listByBizNo((string) $payOrder->pay_no); + } + + return $ledgers; + } + + /** + * 返回启用的支付方式选项,供列表筛选使用。 + */ + private function payTypeOptions(): array + { + return $this->paymentTypeRepository->query() + ->where('status', CommonConstant::STATUS_ENABLED) + ->orderBy('sort_no') + ->orderByDesc('id') + ->get(['id', 'name']) + ->map(function (PaymentType $payType): array { + return [ + 'label' => (string) $payType->name, + 'value' => (int) $payType->id, + ]; + }) + ->values() + ->all(); + } + +} diff --git a/app/service/payment/order/PayOrderReportService.php b/app/service/payment/order/PayOrderReportService.php new file mode 100644 index 0000000..c2b85f4 --- /dev/null +++ b/app/service/payment/order/PayOrderReportService.php @@ -0,0 +1,92 @@ +textFromMap((int) ($row['biz_status'] ?? -1), TradeConstant::orderStatusMap()); + + $row['status_text'] = $this->textFromMap((int) ($row['status'] ?? -1), TradeConstant::orderStatusMap()); + $row['fee_status_text'] = $this->textFromMap((int) ($row['fee_status'] ?? -1), TradeConstant::feeStatusMap()); + $row['settlement_status_text'] = $this->textFromMap((int) ($row['settlement_status'] ?? -1), TradeConstant::settlementStatusMap()); + $row['callback_status_text'] = $this->textFromMap((int) ($row['callback_status'] ?? -1), NotifyConstant::processStatusMap()); + $row['channel_type_text'] = $this->textFromMap((int) ($row['channel_type'] ?? -1), RouteConstant::channelTypeMap()); + $row['channel_mode_text'] = $this->textFromMap((int) ($row['channel_mode'] ?? -1), RouteConstant::channelModeMap()); + + $row['pay_amount_text'] = $this->formatAmount((int) ($row['pay_amount'] ?? 0)); + $row['fee_estimated_amount_text'] = $this->formatAmount((int) ($row['fee_estimated_amount'] ?? 0)); + $row['fee_actual_amount_text'] = $this->formatAmount((int) ($row['fee_actual_amount'] ?? 0)); + $row['biz_order_amount_text'] = $this->formatAmount((int) ($row['biz_order_amount'] ?? 0)); + $row['biz_paid_amount_text'] = $this->formatAmount((int) ($row['biz_paid_amount'] ?? 0)); + $row['biz_refund_amount_text'] = $this->formatAmount((int) ($row['biz_refund_amount'] ?? 0)); + + $row['request_at_text'] = $this->formatDateTime($row['request_at'] ?? null, '—'); + $row['paid_at_text'] = $this->formatDateTime($row['paid_at'] ?? null, '—'); + $row['expire_at_text'] = $this->formatDateTime($row['expire_at'] ?? null, '—'); + $row['closed_at_text'] = $this->formatDateTime($row['closed_at'] ?? null, '—'); + $row['failed_at_text'] = $this->formatDateTime($row['failed_at'] ?? null, '—'); + $row['timeout_at_text'] = $this->formatDateTime($row['timeout_at'] ?? null, '—'); + $row['biz_expire_at_text'] = $this->formatDateTime($row['biz_expire_at'] ?? null, '—'); + $row['biz_paid_at_text'] = $this->formatDateTime($row['biz_paid_at'] ?? null, '—'); + $row['biz_closed_at_text'] = $this->formatDateTime($row['biz_closed_at'] ?? null, '—'); + $row['biz_failed_at_text'] = $this->formatDateTime($row['biz_failed_at'] ?? null, '—'); + $row['biz_timeout_at_text'] = $this->formatDateTime($row['biz_timeout_at'] ?? null, '—'); + + return $row; + } + + /** + * 构造支付时间线。 + */ + public function buildPayTimeline(PayOrder $payOrder): array + { + $extJson = (array) ($payOrder->ext_json ?? []); + + return array_values(array_filter([ + [ + 'status' => 'created', + 'at' => $this->formatDateTime($payOrder->request_at ?? $payOrder->created_at ?? null, '—'), + ], + $payOrder->paid_at ? [ + 'status' => 'success', + 'at' => $this->formatDateTime($payOrder->paid_at, '—'), + ] : null, + $payOrder->closed_at ? [ + 'status' => 'closed', + 'at' => $this->formatDateTime($payOrder->closed_at, '—'), + 'reason' => (string) ($extJson['close_reason'] ?? ''), + ] : null, + $payOrder->failed_at ? [ + 'status' => 'failed', + 'at' => $this->formatDateTime($payOrder->failed_at, '—'), + 'reason' => (string) ($payOrder->channel_error_msg ?: ($extJson['reason'] ?? '')), + ] : null, + $payOrder->timeout_at ? [ + 'status' => 'timeout', + 'at' => $this->formatDateTime($payOrder->timeout_at, '—'), + 'reason' => (string) ($extJson['timeout_reason'] ?? ''), + ] : null, + ])); + } +} diff --git a/app/service/payment/order/PayOrderService.php b/app/service/payment/order/PayOrderService.php new file mode 100644 index 0000000..40648cb --- /dev/null +++ b/app/service/payment/order/PayOrderService.php @@ -0,0 +1,131 @@ +queryService->paginate($filters, $page, $pageSize, $merchantId); + } + + /** + * 查询支付订单详情。 + */ + public function detail(string $payNo, ?int $merchantId = null): array + { + return $this->queryService->detail($payNo, $merchantId); + } + + /** + * 预创建支付尝试。 + */ + public function preparePayAttempt(array $input): array + { + return $this->attemptService->preparePayAttempt($input); + } + + /** + * 标记支付成功。 + */ + public function markPaySuccess(string $payNo, array $input = []): PayOrder + { + return $this->lifecycleService->markPaySuccess($payNo, $input); + } + + /** + * 在当前事务中标记支付成功。 + */ + public function markPaySuccessInCurrentTransaction(string $payNo, array $input = []): PayOrder + { + return $this->lifecycleService->markPaySuccessInCurrentTransaction($payNo, $input); + } + + /** + * 标记支付失败。 + */ + public function markPayFailed(string $payNo, array $input = []): PayOrder + { + return $this->lifecycleService->markPayFailed($payNo, $input); + } + + /** + * 在当前事务中标记支付失败。 + */ + public function markPayFailedInCurrentTransaction(string $payNo, array $input = []): PayOrder + { + return $this->lifecycleService->markPayFailedInCurrentTransaction($payNo, $input); + } + + /** + * 关闭支付单。 + */ + public function closePayOrder(string $payNo, array $input = []): PayOrder + { + return $this->lifecycleService->closePayOrder($payNo, $input); + } + + /** + * 在当前事务中关闭支付单。 + */ + public function closePayOrderInCurrentTransaction(string $payNo, array $input = []): PayOrder + { + return $this->lifecycleService->closePayOrderInCurrentTransaction($payNo, $input); + } + + /** + * 标记支付超时。 + */ + public function timeoutPayOrder(string $payNo, array $input = []): PayOrder + { + return $this->lifecycleService->timeoutPayOrder($payNo, $input); + } + + /** + * 在当前事务中标记支付超时。 + */ + public function timeoutPayOrderInCurrentTransaction(string $payNo, array $input = []): PayOrder + { + return $this->lifecycleService->timeoutPayOrderInCurrentTransaction($payNo, $input); + } + + /** + * 处理渠道回调。 + */ + public function handleChannelCallback(array $input): PayOrder + { + return $this->callbackService->handleChannelCallback($input); + } + + /** + * 按支付单号处理真实第三方回调。 + */ + public function handlePluginCallback(string $payNo, Request $request): string|Response + { + return $this->callbackService->handlePluginCallback($payNo, $request); + } +} diff --git a/app/service/payment/order/RefundCreationService.php b/app/service/payment/order/RefundCreationService.php new file mode 100644 index 0000000..20e7473 --- /dev/null +++ b/app/service/payment/order/RefundCreationService.php @@ -0,0 +1,116 @@ +payOrderRepository->findByPayNo($payNo); + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + if ((int) $payOrder->status !== TradeConstant::ORDER_STATUS_SUCCESS) { + throw new BusinessStateException('订单状态不允许退款', [ + 'pay_no' => $payNo, + 'status' => (int) $payOrder->status, + ]); + } + + $refundAmount = array_key_exists('refund_amount', $input) + ? (int) $input['refund_amount'] + : (int) $payOrder->pay_amount; + + if ($refundAmount !== (int) $payOrder->pay_amount) { + throw new BusinessStateException('当前仅支持整单全额退款'); + } + + $merchantRefundNo = trim((string) ($input['merchant_refund_no'] ?? '')); + if ($merchantRefundNo !== '') { + $existingByMerchantNo = $this->refundOrderRepository->findByMerchantRefundNo((int) $payOrder->merchant_id, $merchantRefundNo); + if ($existingByMerchantNo) { + if ((string) $existingByMerchantNo->pay_no !== $payNo || (int) $existingByMerchantNo->refund_amount !== $refundAmount) { + throw new ConflictException('幂等冲突', [ + 'refund_no' => (string) $existingByMerchantNo->refund_no, + 'pay_no' => (string) $existingByMerchantNo->pay_no, + 'merchant_refund_no' => $merchantRefundNo, + ]); + } + + return $existingByMerchantNo; + } + } + + if ($existingByPayNo = $this->refundOrderRepository->findByPayNo($payNo)) { + if ($merchantRefundNo !== '' && (string) $existingByPayNo->merchant_refund_no !== $merchantRefundNo) { + throw new ConflictException('重复退款', ['pay_no' => $payNo]); + } + + return $existingByPayNo; + } + + $traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no); + + return $this->refundOrderRepository->create([ + 'refund_no' => $this->generateNo('RFD'), + 'merchant_id' => (int) $payOrder->merchant_id, + 'merchant_group_id' => (int) $payOrder->merchant_group_id, + 'biz_no' => (string) $payOrder->biz_no, + 'trace_no' => $traceNo, + 'pay_no' => $payNo, + 'merchant_refund_no' => $merchantRefundNo !== '' ? $merchantRefundNo : $this->generateNo('MRF'), + 'channel_id' => (int) $payOrder->channel_id, + 'refund_amount' => $refundAmount, + 'fee_reverse_amount' => (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_COLLECT ? (int) $payOrder->fee_actual_amount : 0, + 'status' => TradeConstant::REFUND_STATUS_CREATED, + 'channel_request_no' => $this->generateNo('RQR'), + 'reason' => (string) ($input['reason'] ?? ''), + 'request_at' => $this->now(), + 'processing_at' => null, + 'retry_count' => 0, + 'last_error' => '', + 'ext_json' => array_merge($input['ext_json'] ?? [], [ + 'trace_no' => $traceNo, + ]), + ]); + } +} diff --git a/app/service/payment/order/RefundLifecycleService.php b/app/service/payment/order/RefundLifecycleService.php new file mode 100644 index 0000000..1587ba5 --- /dev/null +++ b/app/service/payment/order/RefundLifecycleService.php @@ -0,0 +1,251 @@ +transactionRetry(function () use ($refundNo, $input) { + return $this->markRefundProcessingInCurrentTransaction($refundNo, $input, false); + }); + } + + /** + * 退款重试。 + */ + public function retryRefund(string $refundNo, array $input = []): RefundOrder + { + return $this->transactionRetry(function () use ($refundNo, $input) { + return $this->markRefundProcessingInCurrentTransaction($refundNo, $input, true); + }); + } + + /** + * 在当前事务中标记退款处理中或重试。 + */ + public function markRefundProcessingInCurrentTransaction(string $refundNo, array $input = [], bool $isRetry = false): RefundOrder + { + $refundOrder = $this->refundOrderRepository->findForUpdateByRefundNo($refundNo); + if (!$refundOrder) { + throw new ResourceNotFoundException('退款单不存在', ['refund_no' => $refundNo]); + } + + $currentStatus = (int) $refundOrder->status; + if ($currentStatus === TradeConstant::REFUND_STATUS_PROCESSING) { + return $refundOrder; + } + + if (TradeConstant::isRefundTerminalStatus($currentStatus)) { + return $refundOrder; + } + + if ($currentStatus !== TradeConstant::REFUND_STATUS_CREATED && $currentStatus !== TradeConstant::REFUND_STATUS_FAILED) { + throw new BusinessStateException('退款单状态不允许当前操作', [ + 'refund_no' => $refundNo, + 'status' => $currentStatus, + ]); + } + + if ($currentStatus === TradeConstant::REFUND_STATUS_FAILED && !$isRetry) { + return $refundOrder; + } + + if ($isRetry && $currentStatus !== TradeConstant::REFUND_STATUS_FAILED) { + return $refundOrder; + } + + $refundOrder->status = TradeConstant::REFUND_STATUS_PROCESSING; + $refundOrder->processing_at = $input['processing_at'] ?? $this->now(); + if (empty($refundOrder->request_at)) { + $refundOrder->request_at = $input['request_at'] ?? $refundOrder->processing_at; + } + $refundOrder->last_error = (string) ($input['last_error'] ?? $refundOrder->last_error ?? ''); + if ($isRetry) { + $refundOrder->retry_count = (int) $refundOrder->retry_count + 1; + $refundOrder->channel_request_no = $this->generateNo('RQR'); + } + + $extJson = (array) $refundOrder->ext_json; + $reason = trim((string) ($input['reason'] ?? '')); + if ($reason !== '') { + $extJson[$isRetry ? 'retry_reason' : 'processing_reason'] = $reason; + } + $refundOrder->ext_json = array_merge($extJson, $input['ext_json'] ?? []); + $refundOrder->save(); + + return $refundOrder->refresh(); + } + + /** + * 退款成功。 + * + * 成功后会推进退款单状态,并在平台代收场景下做余额冲减或结算逆向处理。 + * + * @param string $refundNo 退款单号 + * @param array $input 回调或查单入参 + * @return RefundOrder + */ + public function markRefundSuccess(string $refundNo, array $input = []): RefundOrder + { + return $this->transactionRetry(function () use ($refundNo, $input) { + return $this->markRefundSuccessInCurrentTransaction($refundNo, $input); + }); + } + + /** + * 在当前事务中标记退款成功。 + * + * @param string $refundNo 退款单号 + * @param array $input 回调或查单入参 + * @return RefundOrder + */ + public function markRefundSuccessInCurrentTransaction(string $refundNo, array $input = []): RefundOrder + { + $refundOrder = $this->refundOrderRepository->findForUpdateByRefundNo($refundNo); + if (!$refundOrder) { + throw new ResourceNotFoundException('退款单不存在', ['refund_no' => $refundNo]); + } + + $currentStatus = (int) $refundOrder->status; + if ($currentStatus === TradeConstant::REFUND_STATUS_SUCCESS) { + return $refundOrder; + } + + if ($currentStatus === TradeConstant::REFUND_STATUS_FAILED) { + return $refundOrder; + } + + if (TradeConstant::isRefundTerminalStatus($currentStatus)) { + return $refundOrder; + } + + $payOrder = $this->payOrderRepository->findForUpdateByPayNo((string) $refundOrder->pay_no); + if (!$payOrder || (int) $payOrder->status !== TradeConstant::ORDER_STATUS_SUCCESS) { + throw new BusinessStateException('原支付单状态不允许退款', [ + 'refund_no' => $refundNo, + 'pay_no' => (string) $refundOrder->pay_no, + ]); + } + + $traceNo = (string) ($refundOrder->trace_no ?: $refundOrder->biz_no); + if ((int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_COLLECT) { + $reverseAmount = max(0, (int) $payOrder->pay_amount - (int) $payOrder->fee_actual_amount); + if ((int) $payOrder->settlement_status === TradeConstant::SETTLEMENT_STATUS_SETTLED && $reverseAmount > 0) { + $this->merchantAccountService->debitAvailableAmountInCurrentTransaction( + (int) $refundOrder->merchant_id, + $reverseAmount, + (string) $refundOrder->refund_no, + 'REFUND_REVERSE:' . (string) $refundOrder->refund_no, + [ + 'pay_no' => (string) $refundOrder->pay_no, + 'remark' => '平台代收退款冲减', + ], + $traceNo + ); + } + + $payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_REVERSED; + $payOrder->save(); + } + + $refundOrder->status = TradeConstant::REFUND_STATUS_SUCCESS; + $refundOrder->succeeded_at = $input['succeeded_at'] ?? $this->now(); + $refundOrder->channel_refund_no = (string) ($input['channel_refund_no'] ?? $refundOrder->channel_refund_no ?? ''); + $refundOrder->last_error = ''; + $refundOrder->ext_json = array_merge((array) $refundOrder->ext_json, $input['ext_json'] ?? []); + $refundOrder->save(); + + $bizOrder = $this->bizOrderRepository->findForUpdateByBizNo((string) $refundOrder->biz_no); + if ($bizOrder) { + $bizOrder->refund_amount = (int) $bizOrder->order_amount; + if (empty($bizOrder->trace_no)) { + $bizOrder->trace_no = $traceNo; + } + $bizOrder->save(); + } + + return $refundOrder->refresh(); + } + + /** + * 退款失败。 + */ + public function markRefundFailed(string $refundNo, array $input = []): RefundOrder + { + return $this->transactionRetry(function () use ($refundNo, $input) { + return $this->markRefundFailedInCurrentTransaction($refundNo, $input); + }); + } + + /** + * 在当前事务中标记退款失败。 + */ + public function markRefundFailedInCurrentTransaction(string $refundNo, array $input = []): RefundOrder + { + $refundOrder = $this->refundOrderRepository->findForUpdateByRefundNo($refundNo); + if (!$refundOrder) { + throw new ResourceNotFoundException('退款单不存在', ['refund_no' => $refundNo]); + } + + $currentStatus = (int) $refundOrder->status; + if ($currentStatus === TradeConstant::REFUND_STATUS_FAILED) { + return $refundOrder; + } + + if (TradeConstant::isRefundTerminalStatus($currentStatus)) { + return $refundOrder; + } + + if ($currentStatus !== TradeConstant::REFUND_STATUS_CREATED && $currentStatus !== TradeConstant::REFUND_STATUS_PROCESSING) { + throw new BusinessStateException('退款单状态不允许当前操作', [ + 'refund_no' => $refundNo, + 'status' => $currentStatus, + ]); + } + + $refundOrder->status = TradeConstant::REFUND_STATUS_FAILED; + $refundOrder->failed_at = $input['failed_at'] ?? $this->now(); + $refundOrder->channel_refund_no = (string) ($input['channel_refund_no'] ?? $refundOrder->channel_refund_no ?? ''); + $refundOrder->last_error = (string) ($input['last_error'] ?? $refundOrder->last_error ?? ''); + $extJson = (array) $refundOrder->ext_json; + $reason = trim((string) ($input['reason'] ?? '')); + if ($reason !== '') { + $extJson['fail_reason'] = $reason; + } + $refundOrder->ext_json = array_merge($extJson, $input['ext_json'] ?? []); + $refundOrder->save(); + + return $refundOrder->refresh(); + } +} diff --git a/app/service/payment/order/RefundQueryService.php b/app/service/payment/order/RefundQueryService.php new file mode 100644 index 0000000..4e995ae --- /dev/null +++ b/app/service/payment/order/RefundQueryService.php @@ -0,0 +1,285 @@ +buildRefundOrderQuery($merchantId); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('ro.refund_no', 'like', '%' . $keyword . '%') + ->orWhere('ro.pay_no', 'like', '%' . $keyword . '%') + ->orWhere('ro.biz_no', 'like', '%' . $keyword . '%') + ->orWhere('ro.trace_no', 'like', '%' . $keyword . '%') + ->orWhere('ro.merchant_refund_no', 'like', '%' . $keyword . '%') + ->orWhere('ro.channel_request_no', 'like', '%' . $keyword . '%') + ->orWhere('ro.channel_refund_no', 'like', '%' . $keyword . '%') + ->orWhere('ro.reason', 'like', '%' . $keyword . '%') + ->orWhere('ro.last_error', 'like', '%' . $keyword . '%') + ->orWhere('bo.merchant_order_no', 'like', '%' . $keyword . '%') + ->orWhere('bo.subject', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%') + ->orWhere('g.group_name', 'like', '%' . $keyword . '%') + ->orWhere('c.name', 'like', '%' . $keyword . '%') + ->orWhere('t.name', 'like', '%' . $keyword . '%'); + }); + } + + if (($merchantFilter = (int) ($filters['merchant_id'] ?? 0)) > 0) { + $query->where('ro.merchant_id', $merchantFilter); + } + + if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) { + $query->where('po.pay_type_id', $payTypeId); + } + + if (array_key_exists('status', $filters) && $filters['status'] !== '') { + $query->where('ro.status', (int) $filters['status']); + } + + if (array_key_exists('channel_mode', $filters) && $filters['channel_mode'] !== '') { + $query->where('po.channel_mode', (int) $filters['channel_mode']); + } + + $paginator = $query + ->orderByDesc('ro.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + $list = []; + foreach ($paginator->items() as $item) { + $list[] = $this->refundReportService->formatRefundOrderRow((array) $item); + } + + return [ + 'list' => $list, + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'size' => $paginator->perPage(), + 'pay_types' => $this->payTypeOptions(), + ]; + } + + /** + * 查询退款订单详情。 + * + * @param string $refundNo 退款单号 + * @param int|null $merchantId 商户侧强制限定的商户 ID + * @return array{refund_order:array,timeline:array,account_ledgers:array} + */ + public function detail(string $refundNo, ?int $merchantId = null): array + { + $refundNo = trim($refundNo); + if ($refundNo === '') { + throw new ValidationException('refund_no 不能为空'); + } + + $query = $this->buildRefundOrderQuery($merchantId); + $row = $query->where('ro.refund_no', $refundNo)->first(); + if (!$row) { + throw new ResourceNotFoundException('退款单不存在', ['refund_no' => $refundNo]); + } + + $refundOrder = $this->refundReportService->formatRefundOrderRow((array) $row); + $timeline = $this->refundReportService->buildRefundTimeline($row); + $accountLedgers = $this->loadRefundLedgers($row); + + return [ + 'refund_order' => $refundOrder, + 'timeline' => $timeline, + 'account_ledgers' => $accountLedgers, + ]; + } + + /** + * 按退款单号查询退款单,可按商户限制。 + */ + public function findByRefundNo(string $refundNo, ?int $merchantId = null): ?\app\model\payment\RefundOrder + { + $refundNo = trim($refundNo); + if ($refundNo === '') { + throw new ValidationException('refund_no 不能为空'); + } + + $query = $this->refundOrderRepository->query() + ->from('ma_refund_order as ro') + ->select(['ro.*']) + ->where('ro.refund_no', $refundNo); + + if ($merchantId !== null && $merchantId > 0) { + $query->where('ro.merchant_id', $merchantId); + } + + $row = $query->first(); + if (!$row) { + return null; + } + + return $row; + } + + /** + * 构建退款订单基础查询,列表与详情共用。 + */ + private function buildRefundOrderQuery(?int $merchantId = null) + { + $query = $this->refundOrderRepository->query() + ->from('ma_refund_order as ro') + ->leftJoin('ma_pay_order as po', 'po.pay_no', '=', 'ro.pay_no') + ->leftJoin('ma_biz_order as bo', 'bo.biz_no', '=', 'ro.biz_no') + ->leftJoin('ma_merchant as m', 'm.id', '=', 'ro.merchant_id') + ->leftJoin('ma_merchant_group as g', 'g.id', '=', 'ro.merchant_group_id') + ->leftJoin('ma_payment_channel as c', 'c.id', '=', 'ro.channel_id') + ->leftJoin('ma_payment_type as t', 't.id', '=', 'po.pay_type_id') + ->select([ + 'ro.id', + 'ro.refund_no', + 'ro.merchant_id', + 'ro.merchant_group_id', + 'ro.biz_no', + 'ro.trace_no', + 'ro.pay_no', + 'ro.merchant_refund_no', + 'ro.channel_id', + 'ro.refund_amount', + 'ro.fee_reverse_amount', + 'ro.status', + 'ro.channel_request_no', + 'ro.channel_refund_no', + 'ro.reason', + 'ro.request_at', + 'ro.processing_at', + 'ro.succeeded_at', + 'ro.failed_at', + 'ro.retry_count', + 'ro.last_error', + 'ro.ext_json', + 'ro.created_at', + 'ro.updated_at', + 'po.channel_mode', + 'po.channel_type', + 'po.pay_type_id', + 'po.pay_amount as pay_order_amount', + 'po.fee_actual_amount as pay_fee_actual_amount', + 'po.status as pay_status', + 'bo.merchant_order_no', + 'bo.subject', + 'bo.body', + 'bo.status as biz_status', + 'bo.order_amount as biz_order_amount', + 'bo.paid_amount as biz_paid_amount', + 'bo.refund_amount as biz_refund_amount', + 'm.merchant_no', + 'm.merchant_name', + 'm.merchant_short_name', + 'g.group_name as merchant_group_name', + 'c.name as channel_name', + 'c.plugin_code as channel_plugin_code', + 't.code as pay_type_code', + 't.name as pay_type_name', + 't.icon as pay_type_icon', + ]); + + if ($merchantId !== null && $merchantId > 0) { + $query->where('ro.merchant_id', $merchantId); + } + + return $query; + } + + /** + * 加载退款相关资金流水。 + */ + private function loadRefundLedgers(mixed $refundOrder): array + { + $traceNo = trim((string) ($refundOrder->trace_no ?? '')); + $bizNo = trim((string) ($refundOrder->biz_no ?? '')); + $refundNo = trim((string) ($refundOrder->refund_no ?? '')); + + $ledgers = []; + if ($traceNo !== '') { + $ledgers = $this->collectionToArray($this->merchantAccountLedgerRepository->listByTraceNo($traceNo)); + } + + if (empty($ledgers) && $bizNo !== '') { + $ledgers = $this->collectionToArray($this->merchantAccountLedgerRepository->listByBizNo($bizNo)); + } + + if (empty($ledgers) && $refundNo !== '') { + $ledgers = $this->collectionToArray($this->merchantAccountLedgerRepository->listByBizNo($refundNo)); + } + + $rows = []; + foreach ($ledgers as $ledger) { + $rows[] = $this->refundReportService->formatLedgerRow((array) $ledger); + } + + return $rows; + } + + /** + * 将查询结果转换成普通数组。 + */ + private function collectionToArray(iterable $items): array + { + $rows = []; + foreach ($items as $item) { + $rows[] = $item; + } + + return $rows; + } + + /** + * 返回启用的支付方式选项,供筛选使用。 + */ + private function payTypeOptions(): array + { + return $this->paymentTypeRepository->enabledList(['id', 'name']) + ->map(static function ($payType): array { + return [ + 'label' => (string) $payType->name, + 'value' => (int) $payType->id, + ]; + }) + ->values() + ->all(); + } +} diff --git a/app/service/payment/order/RefundReportService.php b/app/service/payment/order/RefundReportService.php new file mode 100644 index 0000000..f8537a9 --- /dev/null +++ b/app/service/payment/order/RefundReportService.php @@ -0,0 +1,100 @@ +textFromMap((int) ($row['status'] ?? -1), TradeConstant::refundStatusMap()); + $row['pay_status_text'] = $this->textFromMap((int) ($row['pay_status'] ?? -1), TradeConstant::orderStatusMap()); + $row['channel_type_text'] = $this->textFromMap((int) ($row['channel_type'] ?? -1), RouteConstant::channelTypeMap()); + $row['channel_mode_text'] = $this->textFromMap((int) ($row['channel_mode'] ?? -1), RouteConstant::channelModeMap()); + + $row['refund_amount_text'] = $this->formatAmount((int) ($row['refund_amount'] ?? 0)); + $row['fee_reverse_amount_text'] = $this->formatAmount((int) ($row['fee_reverse_amount'] ?? 0)); + $row['pay_order_amount_text'] = $this->formatAmount((int) ($row['pay_order_amount'] ?? 0)); + $row['pay_fee_actual_amount_text'] = $this->formatAmount((int) ($row['pay_fee_actual_amount'] ?? 0)); + $row['biz_order_amount_text'] = $this->formatAmount((int) ($row['biz_order_amount'] ?? 0)); + $row['biz_paid_amount_text'] = $this->formatAmount((int) ($row['biz_paid_amount'] ?? 0)); + $row['biz_refund_amount_text'] = $this->formatAmount((int) ($row['biz_refund_amount'] ?? 0)); + + $row['request_at_text'] = $this->formatDateTime($row['request_at'] ?? null, '—'); + $row['processing_at_text'] = $this->formatDateTime($row['processing_at'] ?? null, '—'); + $row['succeeded_at_text'] = $this->formatDateTime($row['succeeded_at'] ?? null, '—'); + $row['failed_at_text'] = $this->formatDateTime($row['failed_at'] ?? null, '—'); + + return $row; + } + + /** + * 构造退款时间线。 + */ + public function buildRefundTimeline(mixed $refundOrder): array + { + $extJson = (array) ($refundOrder->ext_json ?? []); + + return array_values(array_filter([ + [ + 'status' => 'created', + 'label' => '退款单创建', + 'at' => $this->formatDateTime($refundOrder->request_at ?? $refundOrder->created_at ?? null, '—'), + ], + $refundOrder->processing_at ? [ + 'status' => 'processing', + 'label' => '退款处理中', + 'at' => $this->formatDateTime($refundOrder->processing_at, '—'), + 'retry_count' => (int) ($refundOrder->retry_count ?? 0), + 'reason' => (string) ($extJson['retry_reason'] ?? $extJson['processing_reason'] ?? $refundOrder->last_error ?? ''), + ] : null, + $refundOrder->succeeded_at ? [ + 'status' => 'success', + 'label' => '退款成功', + 'at' => $this->formatDateTime($refundOrder->succeeded_at, '—'), + ] : null, + $refundOrder->failed_at ? [ + 'status' => 'failed', + 'label' => '退款失败', + 'at' => $this->formatDateTime($refundOrder->failed_at, '—'), + 'reason' => (string) ($refundOrder->last_error ?: ($extJson['fail_reason'] ?? $refundOrder->reason ?? '')), + ] : null, + ])); + } + + /** + * 格式化退款相关资金流水。 + */ + public function formatLedgerRow(array $row): array + { + $row['biz_type_text'] = $this->textFromMap((int) ($row['biz_type'] ?? -1), LedgerConstant::bizTypeMap()); + $row['event_type_text'] = $this->textFromMap((int) ($row['event_type'] ?? -1), LedgerConstant::eventTypeMap()); + $row['direction_text'] = $this->textFromMap((int) ($row['direction'] ?? -1), LedgerConstant::directionMap()); + $row['amount_text'] = $this->formatAmount((int) ($row['amount'] ?? 0)); + $row['available_before_text'] = $this->formatAmount((int) ($row['available_before'] ?? 0)); + $row['available_after_text'] = $this->formatAmount((int) ($row['available_after'] ?? 0)); + $row['frozen_before_text'] = $this->formatAmount((int) ($row['frozen_before'] ?? 0)); + $row['frozen_after_text'] = $this->formatAmount((int) ($row['frozen_after'] ?? 0)); + $row['created_at_text'] = $this->formatDateTime($row['created_at'] ?? null, '—'); + + return $row; + } +} diff --git a/app/service/payment/order/RefundService.php b/app/service/payment/order/RefundService.php new file mode 100644 index 0000000..999eb4a --- /dev/null +++ b/app/service/payment/order/RefundService.php @@ -0,0 +1,111 @@ +queryService->paginate($filters, $page, $pageSize, $merchantId); + } + + /** + * 查询退款订单详情。 + */ + public function detail(string $refundNo, ?int $merchantId = null): array + { + return $this->queryService->detail($refundNo, $merchantId); + } + + /** + * 创建退款单。 + */ + public function createRefund(array $input): RefundOrder + { + return $this->creationService->createRefund($input); + } + + /** + * 标记退款处理中。 + */ + public function markRefundProcessing(string $refundNo, array $input = []): RefundOrder + { + return $this->lifecycleService->markRefundProcessing($refundNo, $input); + } + + /** + * 退款重试。 + */ + public function retryRefund(string $refundNo, array $input = [], ?int $merchantId = null): RefundOrder + { + if ($merchantId !== null && $merchantId > 0) { + $refundOrder = $this->queryService->findByRefundNo($refundNo, $merchantId); + if (!$refundOrder) { + throw new \app\exception\ResourceNotFoundException('退款单不存在', ['refund_no' => $refundNo]); + } + } + + return $this->lifecycleService->retryRefund($refundNo, $input); + } + + /** + * 在当前事务中标记退款处理中或重试。 + */ + public function markRefundProcessingInCurrentTransaction(string $refundNo, array $input = [], bool $isRetry = false): RefundOrder + { + return $this->lifecycleService->markRefundProcessingInCurrentTransaction($refundNo, $input, $isRetry); + } + + /** + * 退款成功。 + */ + public function markRefundSuccess(string $refundNo, array $input = []): RefundOrder + { + return $this->lifecycleService->markRefundSuccess($refundNo, $input); + } + + /** + * 在当前事务中标记退款成功。 + */ + public function markRefundSuccessInCurrentTransaction(string $refundNo, array $input = []): RefundOrder + { + return $this->lifecycleService->markRefundSuccessInCurrentTransaction($refundNo, $input); + } + + /** + * 退款失败。 + */ + public function markRefundFailed(string $refundNo, array $input = []): RefundOrder + { + return $this->lifecycleService->markRefundFailed($refundNo, $input); + } + + /** + * 在当前事务中标记退款失败。 + */ + public function markRefundFailedInCurrentTransaction(string $refundNo, array $input = []): RefundOrder + { + return $this->lifecycleService->markRefundFailedInCurrentTransaction($refundNo, $input); + } +} diff --git a/app/service/payment/runtime/NotifyService.php b/app/service/payment/runtime/NotifyService.php new file mode 100644 index 0000000..d2fc4aa --- /dev/null +++ b/app/service/payment/runtime/NotifyService.php @@ -0,0 +1,191 @@ +channelNotifyLogRepository->findDuplicate($channelId, $notifyType, $bizNo)) { + return $duplicate; + } + + return $this->channelNotifyLogRepository->create([ + 'notify_no' => (string) ($input['notify_no'] ?? $this->generateNo('CNL')), + 'channel_id' => $channelId, + 'notify_type' => $notifyType, + 'biz_no' => $bizNo, + 'pay_no' => (string) ($input['pay_no'] ?? ''), + 'channel_request_no' => (string) ($input['channel_request_no'] ?? ''), + 'channel_trade_no' => (string) ($input['channel_trade_no'] ?? ''), + 'raw_payload' => $input['raw_payload'] ?? [], + 'verify_status' => (int) ($input['verify_status'] ?? NotifyConstant::VERIFY_STATUS_UNKNOWN), + 'process_status' => (int) ($input['process_status'] ?? NotifyConstant::PROCESS_STATUS_PENDING), + 'retry_count' => (int) ($input['retry_count'] ?? 0), + 'next_retry_at' => $input['next_retry_at'] ?? null, + 'last_error' => (string) ($input['last_error'] ?? ''), + ]); + } + + /** + * 记录支付回调日志。 + * + * 以支付单号 + 回调类型作为去重依据。 + */ + public function recordPayCallback(array $input): PayCallbackLog + { + $payNo = trim((string) ($input['pay_no'] ?? '')); + if ($payNo === '') { + throw new \InvalidArgumentException('pay_no 不能为空'); + } + + $callbackType = (int) ($input['callback_type'] ?? NotifyConstant::CALLBACK_TYPE_ASYNC); + $logs = $this->payCallbackLogRepository->listByPayNo($payNo); + foreach ($logs as $log) { + if ((int) $log->callback_type === $callbackType) { + return $log; + } + } + + return $this->payCallbackLogRepository->create([ + 'pay_no' => $payNo, + 'channel_id' => (int) ($input['channel_id'] ?? 0), + 'callback_type' => $callbackType, + 'request_data' => $input['request_data'] ?? [], + 'verify_status' => (int) ($input['verify_status'] ?? NotifyConstant::VERIFY_STATUS_UNKNOWN), + 'process_status' => (int) ($input['process_status'] ?? NotifyConstant::PROCESS_STATUS_PENDING), + 'process_result' => $input['process_result'] ?? [], + ]); + } + + /** + * 创建商户通知任务。 + * + * 通常用于支付成功、退款成功或清算完成后的商户异步通知。 + */ + public function enqueueMerchantNotify(array $input): NotifyTask + { + return $this->notifyTaskRepository->create([ + 'notify_no' => (string) ($input['notify_no'] ?? $this->generateNo('NTF')), + 'merchant_id' => (int) ($input['merchant_id'] ?? 0), + 'merchant_group_id' => (int) ($input['merchant_group_id'] ?? 0), + 'biz_no' => (string) ($input['biz_no'] ?? ''), + 'pay_no' => (string) ($input['pay_no'] ?? ''), + 'notify_url' => (string) ($input['notify_url'] ?? ''), + 'notify_data' => $input['notify_data'] ?? [], + 'status' => (int) ($input['status'] ?? NotifyConstant::TASK_STATUS_PENDING), + 'retry_count' => (int) ($input['retry_count'] ?? 0), + 'next_retry_at' => $input['next_retry_at'] ?? $this->nextRetryAt(0), + 'last_notify_at' => $input['last_notify_at'] ?? null, + 'last_response' => (string) ($input['last_response'] ?? ''), + ]); + } + + /** + * 标记商户通知成功。 + * + * 成功后会刷新最后通知时间和响应内容。 + */ + public function markTaskSuccess(string $notifyNo, array $input = []): NotifyTask + { + $task = $this->notifyTaskRepository->findByNotifyNo($notifyNo); + if (!$task) { + throw new \InvalidArgumentException('通知任务不存在'); + } + + $task->status = NotifyConstant::TASK_STATUS_SUCCESS; + $task->last_notify_at = $input['last_notify_at'] ?? $this->now(); + $task->last_response = (string) ($input['last_response'] ?? ''); + $task->save(); + + return $task->refresh(); + } + + /** + * 标记商户通知失败并计算下次重试时间。 + * + * 失败后会累计重试次数,并根据退避策略生成下一次重试时间。 + */ + public function markTaskFailed(string $notifyNo, array $input = []): NotifyTask + { + $task = $this->notifyTaskRepository->findByNotifyNo($notifyNo); + if (!$task) { + throw new \InvalidArgumentException('通知任务不存在'); + } + + $retryCount = (int) $task->retry_count + 1; + $task->status = NotifyConstant::TASK_STATUS_FAILED; + $task->retry_count = $retryCount; + $task->last_notify_at = $input['last_notify_at'] ?? $this->now(); + $task->last_response = (string) ($input['last_response'] ?? ''); + $task->next_retry_at = $this->nextRetryAt($retryCount); + $task->save(); + + return $task->refresh(); + } + + /** + * 获取待重试任务。 + */ + public function listRetryableTasks(): iterable + { + return $this->notifyTaskRepository->listRetryable(NotifyConstant::TASK_STATUS_FAILED); + } + + /** + * 根据重试次数计算下次重试时间。 + * + * 使用简单的指数退避思路控制重试频率。 + */ + private function nextRetryAt(int $retryCount): string + { + $retryCount = max(0, $retryCount); + $delay = match (true) { + $retryCount <= 0 => 60, + $retryCount === 1 => 300, + $retryCount === 2 => 900, + default => 1800, + }; + + return FormatHelper::timestamp(time() + $delay); + } +} + diff --git a/app/service/payment/runtime/PaymentPluginFactoryService.php b/app/service/payment/runtime/PaymentPluginFactoryService.php new file mode 100644 index 0000000..b361711 --- /dev/null +++ b/app/service/payment/runtime/PaymentPluginFactoryService.php @@ -0,0 +1,212 @@ +paymentChannelRepository->find((int) $channel); + + if (!$channelModel) { + throw new PaymentException('支付通道不存在', 40402, ['channel_id' => (int) $channel]); + } + + $plugin = $this->resolvePlugin((string) $channelModel->plugin_code, $allowDisabled); + $payTypeCode = $this->resolvePayTypeCode((int) ($payTypeId ?: $channelModel->pay_type_id)); + if (!$allowDisabled && !$this->pluginSupportsPayType($plugin, $payTypeCode)) { + throw new PaymentException('支付插件不支持当前支付方式', 40210, [ + 'plugin_code' => (string) $plugin->code, + 'pay_type_code' => $payTypeCode, + 'channel_id' => (int) $channelModel->id, + ]); + } + + $instance = $this->instantiatePlugin((string) $plugin->class_name); + $instance->init($this->buildChannelConfig($channelModel, $plugin)); + + return $instance; + } + + public function createByPayOrder(PayOrder $payOrder, bool $allowDisabled = true): PaymentInterface + { + return $this->createByChannel((int) $payOrder->channel_id, (int) $payOrder->pay_type_id, $allowDisabled); + } + + public function ensureChannelSupportsPayType(PaymentChannel $channel, int $payTypeId): void + { + $plugin = $this->resolvePlugin((string) $channel->plugin_code, false); + $payTypeCode = $this->resolvePayTypeCode($payTypeId); + + if (!$this->pluginSupportsPayType($plugin, $payTypeCode)) { + throw new PaymentException('支付插件不支持当前支付方式', 40210, [ + 'plugin_code' => (string) $plugin->code, + 'pay_type_code' => $payTypeCode, + 'channel_id' => (int) $channel->id, + ]); + } + } + + public function pluginPayTypes(string $pluginCode, bool $allowDisabled = false): array + { + $plugin = $this->resolvePlugin($pluginCode, $allowDisabled); + + return $this->normalizeCodes($plugin->pay_types ?? []); + } + + private function buildChannelConfig(PaymentChannel $channel, PaymentPlugin $plugin): array + { + $config = []; + $configId = (int) $channel->api_config_id; + + if ($configId > 0) { + $pluginConf = $this->paymentPluginConfRepository->find($configId); + if (!$pluginConf) { + throw new PaymentException('支付插件配置不存在', 40403, [ + 'api_config_id' => $configId, + 'channel_id' => (int) $channel->id, + ]); + } + + if ((string) $pluginConf->plugin_code !== (string) $plugin->code) { + throw new PaymentException('支付插件与配置不匹配', 40211, [ + 'channel_id' => (int) $channel->id, + 'plugin_code' => (string) $plugin->code, + 'config_plugin_code' => (string) $pluginConf->plugin_code, + ]); + } + + $config = (array) ($pluginConf->config ?? []); + $config['settlement_cycle_type'] = (int) ($pluginConf->settlement_cycle_type ?? 1); + $config['settlement_cutoff_time'] = (string) ($pluginConf->settlement_cutoff_time ?? '23:59:59'); + } + + $config['plugin_code'] = (string) $plugin->code; + $config['plugin_name'] = (string) $plugin->name; + $config['channel_id'] = (int) $channel->id; + $config['merchant_id'] = (int) $channel->merchant_id; + $config['channel_mode'] = (int) $channel->channel_mode; + $config['pay_type_id'] = (int) $channel->pay_type_id; + $config['api_config_id'] = $configId; + $config['enabled_pay_types'] = $this->normalizeCodes($plugin->pay_types ?? []); + $config['enabled_transfer_types'] = $this->normalizeCodes($plugin->transfer_types ?? []); + + return $config; + } + + private function instantiatePlugin(string $className): PaymentInterface & PayPluginInterface + { + $className = $this->resolvePluginClassName($className); + if ($className === '') { + throw new PaymentException('支付插件未配置实现类', 40212); + } + + if (!class_exists($className)) { + throw new PaymentException('支付插件实现类不存在', 40404, ['class_name' => $className]); + } + + $instance = container_make($className, []); + if (!$instance instanceof PaymentInterface || !$instance instanceof PayPluginInterface) { + throw new PaymentException('支付插件必须同时实现 PaymentInterface 与 PayPluginInterface', 40213, ['class_name' => $className]); + } + + return $instance; + } + + private function resolvePluginClassName(string $className): string + { + $className = trim($className); + if ($className === '') { + return ''; + } + + if (str_contains($className, '\\')) { + return $className; + } + + return 'app\\common\\payment\\' . $className; + } + + private function resolvePlugin(string $pluginCode, bool $allowDisabled): PaymentPlugin + { + /** @var PaymentPlugin|null $plugin */ + $plugin = $this->paymentPluginRepository->findByCode($pluginCode); + if (!$plugin) { + throw new PaymentException('支付插件不存在', 40401, ['plugin_code' => $pluginCode]); + } + + if (!$allowDisabled && (int) $plugin->status !== 1) { + throw new PaymentException('支付插件已禁用', 40214, ['plugin_code' => $pluginCode]); + } + + return $plugin; + } + + private function resolvePayTypeCode(int $payTypeId): string + { + $paymentType = $this->paymentTypeRepository->find($payTypeId); + if (!$paymentType) { + throw new PaymentException('支付方式不存在', 40405, ['pay_type_id' => $payTypeId]); + } + + return trim((string) $paymentType->code); + } + + private function pluginSupportsPayType(PaymentPlugin $plugin, string $payTypeCode): bool + { + $payTypeCode = trim($payTypeCode); + if ($payTypeCode === '') { + return false; + } + + return in_array($payTypeCode, $this->normalizeCodes($plugin->pay_types ?? []), true); + } + + private function normalizeCodes(mixed $codes): array + { + if (is_string($codes)) { + $decoded = json_decode($codes, true); + $codes = is_array($decoded) ? $decoded : [$codes]; + } + + if (!is_array($codes)) { + return []; + } + + $normalized = []; + foreach ($codes as $code) { + $value = trim((string) $code); + if ($value !== '') { + $normalized[] = $value; + } + } + + return array_values(array_unique($normalized)); + } +} diff --git a/app/service/payment/runtime/PaymentPluginManager.php b/app/service/payment/runtime/PaymentPluginManager.php new file mode 100644 index 0000000..83e35d7 --- /dev/null +++ b/app/service/payment/runtime/PaymentPluginManager.php @@ -0,0 +1,42 @@ +factoryService->createByChannel($channel, $payTypeId, $allowDisabled); + } + + public function createByPayOrder(PayOrder $payOrder, bool $allowDisabled = true): PaymentInterface & PayPluginInterface + { + return $this->factoryService->createByPayOrder($payOrder, $allowDisabled); + } + + public function ensureChannelSupportsPayType(PaymentChannel $channel, int $payTypeId): void + { + $this->factoryService->ensureChannelSupportsPayType($channel, $payTypeId); + } + + public function pluginPayTypes(string $pluginCode, bool $allowDisabled = false): array + { + return $this->factoryService->pluginPayTypes($pluginCode, $allowDisabled); + } +} diff --git a/app/service/payment/runtime/PaymentRouteResolverService.php b/app/service/payment/runtime/PaymentRouteResolverService.php new file mode 100644 index 0000000..f9266a8 --- /dev/null +++ b/app/service/payment/runtime/PaymentRouteResolverService.php @@ -0,0 +1,276 @@ + 轮询组 -> 支付通道的编排与选择。 + */ +class PaymentRouteResolverService extends BaseService +{ + public function __construct( + protected PaymentPollGroupBindRepository $bindRepository, + protected PaymentPollGroupRepository $pollGroupRepository, + protected PaymentPollGroupChannelRepository $pollGroupChannelRepository, + protected PaymentChannelRepository $channelRepository, + protected ChannelDailyStatRepository $channelDailyStatRepository, + protected PaymentPluginRepository $paymentPluginRepository, + protected PaymentTypeRepository $paymentTypeRepository + ) { + } + + /** + * 按商户分组和支付方式解析路由。 + * + * @return array{bind:mixed,poll_group:mixed,candidates:array,selected_channel:array} + */ + public function resolveByMerchantGroup(int $merchantGroupId, int $payTypeId, int $payAmount, array $context = []): array + { + if ($merchantGroupId <= 0 || $payTypeId <= 0 || $payAmount <= 0) { + throw new ValidationException('路由参数不合法'); + } + + $bind = $this->bindRepository->findActiveByMerchantGroupAndPayType($merchantGroupId, $payTypeId); + if (!$bind) { + throw new ResourceNotFoundException('路由不存在', [ + 'merchant_group_id' => $merchantGroupId, + 'pay_type_id' => $payTypeId, + ]); + } + + /** @var PaymentPollGroup|null $pollGroup */ + $pollGroup = $this->pollGroupRepository->find((int) $bind->poll_group_id); + if (!$pollGroup || (int) $pollGroup->status !== CommonConstant::STATUS_ENABLED) { + throw new ResourceNotFoundException('路由不存在', [ + 'merchant_group_id' => $merchantGroupId, + 'pay_type_id' => $payTypeId, + 'poll_group_id' => (int) ($bind->poll_group_id ?? 0), + ]); + } + + $candidateRows = $this->pollGroupChannelRepository->listByPollGroupId((int) $pollGroup->id); + if ($candidateRows->isEmpty()) { + throw new BusinessStateException('支付通道不可用', [ + 'poll_group_id' => (int) $pollGroup->id, + ]); + } + + $channelIds = $candidateRows->pluck('channel_id')->all(); + $channels = $this->channelRepository->query() + ->whereIn('id', $channelIds) + ->where('status', CommonConstant::STATUS_ENABLED) + ->get() + ->keyBy('id'); + $pluginCodes = $channels->pluck('plugin_code')->filter()->unique()->values()->all(); + $plugins = []; + if (!empty($pluginCodes)) { + $plugins = $this->paymentPluginRepository->query() + ->whereIn('code', $pluginCodes) + ->get() + ->keyBy('code') + ->all(); + } + $paymentType = $this->paymentTypeRepository->find($payTypeId); + $payTypeCode = trim((string) ($paymentType->code ?? '')); + + $statDate = $context['stat_date'] ?? FormatHelper::timestamp(time(), 'Y-m-d'); + $payAmount = (int) $payAmount; + $eligible = []; + + foreach ($candidateRows as $row) { + $channelId = (int) $row->channel_id; + + /** @var PaymentChannel|null $channel */ + $channel = $channels->get($channelId); + if (!$channel) { + continue; + } + + if ((int) $channel->pay_type_id !== $payTypeId) { + continue; + } + + $plugin = $plugins[(string) $channel->plugin_code] ?? null; + if (!$plugin || (int) $plugin->status !== CommonConstant::STATUS_ENABLED) { + continue; + } + + $pluginPayTypes = is_array($plugin->pay_types) ? $plugin->pay_types : []; + $pluginPayTypes = array_values(array_filter(array_map(static fn ($item) => trim((string) $item), $pluginPayTypes))); + if ($payTypeCode === '' || !in_array($payTypeCode, $pluginPayTypes, true)) { + continue; + } + + if (!$this->isAmountAllowed($channel, $payAmount)) { + continue; + } + + $stat = $this->channelDailyStatRepository->findByChannelAndDate($channelId, $statDate); + if (!$this->isDailyLimitAllowed($channel, $payAmount, $statDate, $stat)) { + continue; + } + + $eligible[] = [ + 'channel' => $channel, + 'poll_group_channel' => $row, + 'daily_stat' => $stat, + 'health_score' => (int) ($stat->health_score ?? 0), + 'success_rate_bp' => (int) ($stat->success_rate_bp ?? 0), + 'avg_latency_ms' => (int) ($stat->avg_latency_ms ?? 0), + 'weight' => max(1, (int) $row->weight), + 'is_default' => (int) $row->is_default, + 'sort_no' => (int) $row->sort_no, + ]; + } + + if (empty($eligible)) { + throw new BusinessStateException('支付通道不可用', [ + 'poll_group_id' => (int) $pollGroup->id, + 'merchant_group_id' => $merchantGroupId, + 'pay_type_id' => $payTypeId, + ]); + } + + $routeMode = (int) $pollGroup->route_mode; + $ordered = $this->sortCandidates($eligible, $routeMode); + $selected = $this->selectChannel($ordered, $routeMode, (int) $pollGroup->id); + + return [ + 'bind' => $bind, + 'poll_group' => $pollGroup, + 'candidates' => $ordered, + 'selected_channel' => $selected, + ]; + } + + private function isAmountAllowed(PaymentChannel $channel, int $payAmount): bool + { + if ((int) $channel->min_amount > 0 && $payAmount < (int) $channel->min_amount) { + return false; + } + + if ((int) $channel->max_amount > 0 && $payAmount > (int) $channel->max_amount) { + return false; + } + + return true; + } + + private function isDailyLimitAllowed(PaymentChannel $channel, int $payAmount, string $statDate, ?object $stat = null): bool + { + if ((int) $channel->daily_limit_amount <= 0 && (int) $channel->daily_limit_count <= 0) { + return true; + } + + $stat ??= $this->channelDailyStatRepository->findByChannelAndDate((int) $channel->id, $statDate); + $currentAmount = (int) ($stat->pay_amount ?? 0); + $currentCount = (int) ($stat->pay_success_count ?? 0); + + if ((int) $channel->daily_limit_amount > 0 && $currentAmount + $payAmount > (int) $channel->daily_limit_amount) { + return false; + } + + if ((int) $channel->daily_limit_count > 0 && $currentCount + 1 > (int) $channel->daily_limit_count) { + return false; + } + + return true; + } + + private function sortCandidates(array $candidates, int $routeMode): array + { + usort($candidates, function (array $left, array $right) use ($routeMode) { + if ( + $routeMode === RouteConstant::ROUTE_MODE_FIRST_AVAILABLE + && (int) $left['is_default'] !== (int) $right['is_default'] + ) { + return (int) $right['is_default'] <=> (int) $left['is_default']; + } + + if ((int) $left['sort_no'] !== (int) $right['sort_no']) { + return (int) $left['sort_no'] <=> (int) $right['sort_no']; + } + + return (int) $left['channel']->id <=> (int) $right['channel']->id; + }); + + return $candidates; + } + + private function selectChannel(array $candidates, int $routeMode, int $pollGroupId): array + { + if (count($candidates) === 1) { + return $candidates[0]; + } + + return match ($routeMode) { + RouteConstant::ROUTE_MODE_WEIGHTED => $this->selectWeightedChannel($candidates), + RouteConstant::ROUTE_MODE_ORDER => $this->selectSequentialChannel($candidates, $pollGroupId), + RouteConstant::ROUTE_MODE_FIRST_AVAILABLE => $this->selectDefaultChannel($candidates), + default => $candidates[0], + }; + } + + private function selectWeightedChannel(array $candidates): array + { + $totalWeight = array_sum(array_map(static fn (array $item) => max(1, (int) $item['weight']), $candidates)); + $random = random_int(1, max(1, $totalWeight)); + + foreach ($candidates as $candidate) { + $random -= max(1, (int) $candidate['weight']); + if ($random <= 0) { + return $candidate; + } + } + + return $candidates[0]; + } + + private function selectSequentialChannel(array $candidates, int $pollGroupId): array + { + if ($pollGroupId <= 0) { + return $candidates[0]; + } + + try { + $cursorKey = sprintf('payment:route:round_robin:%d', $pollGroupId); + $cursor = (int) Redis::incr($cursorKey); + Redis::expire($cursorKey, 30 * 86400); + $index = max(0, ($cursor - 1) % count($candidates)); + + return $candidates[$index] ?? $candidates[0]; + } catch (\Throwable) { + return $candidates[0]; + } + } + + private function selectDefaultChannel(array $candidates): array + { + foreach ($candidates as $candidate) { + if ((int) ($candidate['is_default'] ?? 0) === 1) { + return $candidate; + } + } + + return $candidates[0]; + } +} diff --git a/app/service/payment/runtime/PaymentRouteService.php b/app/service/payment/runtime/PaymentRouteService.php new file mode 100644 index 0000000..08027c3 --- /dev/null +++ b/app/service/payment/runtime/PaymentRouteService.php @@ -0,0 +1,26 @@ +resolverService->resolveByMerchantGroup($merchantGroupId, $payTypeId, $payAmount, $context); + } +} diff --git a/app/service/payment/settlement/SettlementLifecycleService.php b/app/service/payment/settlement/SettlementLifecycleService.php new file mode 100644 index 0000000..9b318c4 --- /dev/null +++ b/app/service/payment/settlement/SettlementLifecycleService.php @@ -0,0 +1,270 @@ +generateNo('STL'); + } + + if ($existing = $this->settlementOrderRepository->findBySettleNo($settleNo)) { + return $existing; + } + + $merchantId = (int) ($input['merchant_id'] ?? 0); + $merchantGroupId = (int) ($input['merchant_group_id'] ?? 0); + $channelId = (int) ($input['channel_id'] ?? 0); + $cycleType = (int) ($input['cycle_type'] ?? TradeConstant::SETTLEMENT_CYCLE_OTHER); + $cycleKey = trim((string) ($input['cycle_key'] ?? '')); + + if ($merchantId <= 0 || $merchantGroupId <= 0 || $channelId <= 0 || $cycleKey === '') { + throw new ValidationException('清算单入参不完整'); + } + + return $this->transactionRetry(function () use ($settleNo, $input, $items, $merchantId, $merchantGroupId, $channelId, $cycleType, $cycleKey) { + $summary = $this->buildSummary($items, $input); + $traceNo = trim((string) ($input['trace_no'] ?? $settleNo)); + + $settlementOrder = $this->settlementOrderRepository->create([ + 'settle_no' => $settleNo, + 'trace_no' => $traceNo, + 'merchant_id' => $merchantId, + 'merchant_group_id' => $merchantGroupId, + 'channel_id' => $channelId, + 'cycle_type' => $cycleType, + 'cycle_key' => $cycleKey, + 'status' => (int) ($input['status'] ?? TradeConstant::SETTLEMENT_STATUS_PENDING), + 'gross_amount' => $summary['gross_amount'], + 'fee_amount' => $summary['fee_amount'], + 'refund_amount' => $summary['refund_amount'], + 'fee_reverse_amount' => $summary['fee_reverse_amount'], + 'net_amount' => $summary['net_amount'], + 'accounted_amount' => $summary['accounted_amount'], + 'generated_at' => $input['generated_at'] ?? $this->now(), + 'failed_at' => null, + 'ext_json' => $input['ext_json'] ?? [], + ]); + + foreach ($items as $item) { + $this->settlementItemRepository->create([ + 'settle_no' => $settleNo, + 'merchant_id' => $merchantId, + 'merchant_group_id' => $merchantGroupId, + 'channel_id' => $channelId, + 'pay_no' => (string) ($item['pay_no'] ?? ''), + 'refund_no' => (string) ($item['refund_no'] ?? ''), + 'pay_amount' => (int) ($item['pay_amount'] ?? 0), + 'fee_amount' => (int) ($item['fee_amount'] ?? 0), + 'refund_amount' => (int) ($item['refund_amount'] ?? 0), + 'fee_reverse_amount' => (int) ($item['fee_reverse_amount'] ?? 0), + 'net_amount' => (int) ($item['net_amount'] ?? 0), + 'item_status' => (int) ($item['item_status'] ?? 0), + ]); + } + + return $settlementOrder->refresh(); + }); + } + + /** + * 清算入账成功。 + * + * 会把清算净额计入商户可提现余额,并同步标记清算单与清算明细为已完成。 + * + * @param string $settleNo 清算单号 + * @return SettlementOrder + */ + public function completeSettlement(string $settleNo): SettlementOrder + { + return $this->transactionRetry(function () use ($settleNo) { + $settlementOrder = $this->settlementOrderRepository->findForUpdateBySettleNo($settleNo); + if (!$settlementOrder) { + throw new ResourceNotFoundException('清结算单不存在', ['settle_no' => $settleNo]); + } + + $currentStatus = (int) $settlementOrder->status; + if ($currentStatus === TradeConstant::SETTLEMENT_STATUS_SETTLED) { + return $settlementOrder; + } + + if (TradeConstant::isSettlementTerminalStatus($currentStatus)) { + return $settlementOrder; + } + + if (!in_array($currentStatus, TradeConstant::settlementMutableStatuses(), true)) { + throw new BusinessStateException('清结算单状态不允许当前操作', [ + 'settle_no' => $settleNo, + 'status' => $currentStatus, + ]); + } + + if ((int) $settlementOrder->accounted_amount > 0) { + $this->merchantAccountService->creditAvailableAmountInCurrentTransaction( + (int) $settlementOrder->merchant_id, + (int) $settlementOrder->accounted_amount, + $settleNo, + 'SETTLEMENT_CREDIT:' . $settleNo, + [ + 'settle_no' => $settleNo, + 'remark' => '清算入账', + ], + (string) ($settlementOrder->trace_no ?: $settleNo) + ); + } + + $settlementOrder->status = TradeConstant::SETTLEMENT_STATUS_SETTLED; + $settlementOrder->accounted_at = $this->now(); + $settlementOrder->completed_at = $this->now(); + $settlementOrder->save(); + + $items = $this->settlementItemRepository->listBySettleNo($settleNo); + foreach ($items as $item) { + $item->item_status = TradeConstant::SETTLEMENT_STATUS_SETTLED; + $item->save(); + + if (!empty($item->pay_no)) { + $payOrder = $this->payOrderRepository->findByPayNo((string) $item->pay_no); + if ($payOrder) { + $payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_SETTLED; + $payOrder->save(); + } + } + } + + return $settlementOrder->refresh(); + }); + } + + /** + * 清算失败。 + * + * 仅用于清算批次未成功入账时的终态标记。 + * + * @param string $settleNo 清算单号 + * @param string $reason 失败原因 + * @return SettlementOrder + */ + public function failSettlement(string $settleNo, string $reason = ''): SettlementOrder + { + return $this->transactionRetry(function () use ($settleNo, $reason) { + $settlementOrder = $this->settlementOrderRepository->findForUpdateBySettleNo($settleNo); + if (!$settlementOrder) { + throw new ResourceNotFoundException('清结算单不存在', ['settle_no' => $settleNo]); + } + + $currentStatus = (int) $settlementOrder->status; + if ($currentStatus === TradeConstant::SETTLEMENT_STATUS_REVERSED) { + return $settlementOrder; + } + + if (TradeConstant::isSettlementTerminalStatus($currentStatus)) { + return $settlementOrder; + } + + if (!in_array($currentStatus, TradeConstant::settlementMutableStatuses(), true)) { + throw new BusinessStateException('清结算单状态不允许当前操作', [ + 'settle_no' => $settleNo, + 'status' => $currentStatus, + ]); + } + + $settlementOrder->status = TradeConstant::SETTLEMENT_STATUS_REVERSED; + $settlementOrder->fail_reason = $reason; + $settlementOrder->failed_at = $this->now(); + $extJson = (array) $settlementOrder->ext_json; + if (trim($reason) !== '') { + $extJson['fail_reason'] = $reason; + } + $settlementOrder->ext_json = $extJson; + $settlementOrder->save(); + + $items = $this->settlementItemRepository->listBySettleNo($settleNo); + foreach ($items as $item) { + $item->item_status = TradeConstant::SETTLEMENT_STATUS_REVERSED; + $item->save(); + } + + return $settlementOrder->refresh(); + }); + } + + /** + * 根据清算明细构造汇总数据。 + */ + private function buildSummary(array $items, array $input): array + { + if (!empty($items)) { + $grossAmount = 0; + $feeAmount = 0; + $refundAmount = 0; + $feeReverseAmount = 0; + $netAmount = 0; + + foreach ($items as $item) { + $grossAmount += (int) ($item['pay_amount'] ?? 0); + $feeAmount += (int) ($item['fee_amount'] ?? 0); + $refundAmount += (int) ($item['refund_amount'] ?? 0); + $feeReverseAmount += (int) ($item['fee_reverse_amount'] ?? 0); + $netAmount += (int) ($item['net_amount'] ?? 0); + } + + return [ + 'gross_amount' => $grossAmount, + 'fee_amount' => $feeAmount, + 'refund_amount' => $refundAmount, + 'fee_reverse_amount' => $feeReverseAmount, + 'net_amount' => $netAmount, + 'accounted_amount' => $input['accounted_amount'] ?? $netAmount, + ]; + } + + return [ + 'gross_amount' => (int) ($input['gross_amount'] ?? 0), + 'fee_amount' => (int) ($input['fee_amount'] ?? 0), + 'refund_amount' => (int) ($input['refund_amount'] ?? 0), + 'fee_reverse_amount' => (int) ($input['fee_reverse_amount'] ?? 0), + 'net_amount' => (int) ($input['net_amount'] ?? 0), + 'accounted_amount' => (int) ($input['accounted_amount'] ?? 0), + ]; + } +} diff --git a/app/service/payment/settlement/SettlementOrderQueryService.php b/app/service/payment/settlement/SettlementOrderQueryService.php new file mode 100644 index 0000000..8fc3efe --- /dev/null +++ b/app/service/payment/settlement/SettlementOrderQueryService.php @@ -0,0 +1,226 @@ +baseQuery($merchantId); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('s.settle_no', 'like', '%' . $keyword . '%') + ->orWhere('s.trace_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') + ->orWhere('g.group_name', 'like', '%' . $keyword . '%') + ->orWhere('c.name', 'like', '%' . $keyword . '%'); + }); + } + + $merchantId = (string) ($filters['merchant_id'] ?? ''); + if ($merchantId !== '') { + $query->where('s.merchant_id', (int) $merchantId); + } + + $channelId = (string) ($filters['channel_id'] ?? ''); + if ($channelId !== '') { + $query->where('s.channel_id', (int) $channelId); + } + + $status = (string) ($filters['status'] ?? ''); + if ($status !== '') { + $query->where('s.status', (int) $status); + } + + $cycleType = (string) ($filters['cycle_type'] ?? ''); + if ($cycleType !== '') { + $query->where('s.cycle_type', (int) $cycleType); + } + + $paginator = $query + ->orderByDesc('s.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + $paginator->getCollection()->transform(function ($row) { + return $this->decorateRow($row); + }); + + return $paginator; + } + + /** + * 按清算单号查询详情。 + */ + public function findBySettleNo(string $settleNo, ?int $merchantId = null): ?SettlementOrder + { + $row = $this->baseQuery($merchantId) + ->where('s.settle_no', $settleNo) + ->first(); + + return $row ?: null; + } + + /** + * 查询清算订单详情。 + */ + public function detail(string $settleNo, ?int $merchantId = null): array + { + $settleNo = trim($settleNo); + if ($settleNo === '') { + throw new ValidationException('settle_no 不能为空'); + } + + $settlementOrder = $this->findBySettleNo($settleNo, $merchantId); + if (!$settlementOrder) { + throw new ResourceNotFoundException('清算单不存在', ['settle_no' => $settleNo]); + } + + $traceNo = trim((string) ($settlementOrder->trace_no ?: $settlementOrder->settle_no)); + $accountLedgers = $traceNo !== '' + ? $this->merchantAccountLedgerRepository->listByTraceNo($traceNo) + : collect(); + + if ($accountLedgers->isEmpty()) { + $accountLedgers = $this->merchantAccountLedgerRepository->listByBizNo((string) $settlementOrder->settle_no); + } + + return [ + 'settlement_order' => $settlementOrder, + 'items' => $this->settlementItemRepository->listBySettleNo($settleNo), + 'account_ledgers' => $accountLedgers, + 'timeline' => $this->buildTimeline($settlementOrder), + ]; + } + + /** + * 构建时间线。 + */ + public function buildTimeline(?SettlementOrder $settlementOrder): array + { + if (!$settlementOrder) { + return []; + } + + return array_values(array_filter([ + [ + 'title' => '生成清算单', + 'status' => 'finish', + 'at' => $this->formatDateTime($settlementOrder->generated_at ?? null), + ], + $settlementOrder->accounted_at ? [ + 'title' => '入账处理', + 'status' => 'finish', + 'at' => $this->formatDateTime($settlementOrder->accounted_at ?? null), + ] : null, + $settlementOrder->completed_at ? [ + 'title' => '清算完成', + 'status' => 'finish', + 'at' => $this->formatDateTime($settlementOrder->completed_at ?? null), + ] : null, + $settlementOrder->failed_at ? [ + 'title' => '清算失败', + 'status' => 'error', + 'at' => $this->formatDateTime($settlementOrder->failed_at ?? null), + 'reason' => (string) ($settlementOrder->fail_reason ?? ''), + ] : null, + ])); + } + + /** + * 格式化单条记录。 + */ + private function decorateRow(object $row): object + { + $row->cycle_type_text = (string) (TradeConstant::settlementCycleMap()[(int) $row->cycle_type] ?? '未知'); + $row->status_text = (string) (TradeConstant::settlementStatusMap()[(int) $row->status] ?? '未知'); + $row->gross_amount_text = $this->formatAmount((int) $row->gross_amount); + $row->fee_amount_text = $this->formatAmount((int) $row->fee_amount); + $row->refund_amount_text = $this->formatAmount((int) $row->refund_amount); + $row->fee_reverse_amount_text = $this->formatAmount((int) $row->fee_reverse_amount); + $row->net_amount_text = $this->formatAmount((int) $row->net_amount); + $row->accounted_amount_text = $this->formatAmount((int) $row->accounted_amount); + $row->generated_at_text = $this->formatDateTime($row->generated_at ?? null); + $row->accounted_at_text = $this->formatDateTime($row->accounted_at ?? null); + $row->completed_at_text = $this->formatDateTime($row->completed_at ?? null); + $row->failed_at_text = $this->formatDateTime($row->failed_at ?? null); + $row->ext_json_text = $this->formatJson($row->ext_json ?? null); + + return $row; + } + + /** + * 统一构建查询。 + */ + private function baseQuery(?int $merchantId = null) + { + $query = $this->settlementOrderRepository->query() + ->from('ma_settlement_order as s') + ->leftJoin('ma_merchant as m', 's.merchant_id', '=', 'm.id') + ->leftJoin('ma_merchant_group as g', 'm.group_id', '=', 'g.id') + ->leftJoin('ma_payment_channel as c', 's.channel_id', '=', 'c.id') + ->select([ + 's.id', + 's.settle_no', + 's.trace_no', + 's.merchant_id', + 's.merchant_group_id', + 's.channel_id', + 's.cycle_type', + 's.cycle_key', + 's.status', + 's.gross_amount', + 's.fee_amount', + 's.refund_amount', + 's.fee_reverse_amount', + 's.net_amount', + 's.accounted_amount', + 's.generated_at', + 's.accounted_at', + 's.completed_at', + 's.failed_at', + 's.fail_reason', + 's.ext_json', + 's.created_at', + 's.updated_at', + ]) + ->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no") + ->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name") + ->selectRaw("COALESCE(g.group_name, '') AS merchant_group_name") + ->selectRaw("COALESCE(c.name, '') AS channel_name"); + + if ($merchantId !== null && $merchantId > 0) { + $query->where('s.merchant_id', $merchantId); + } + + return $query; + } + +} diff --git a/app/service/payment/settlement/SettlementService.php b/app/service/payment/settlement/SettlementService.php new file mode 100644 index 0000000..a4330ee --- /dev/null +++ b/app/service/payment/settlement/SettlementService.php @@ -0,0 +1,55 @@ +lifecycleService->createSettlementOrder($input, $items); + } + + /** + * 查询清算订单详情。 + */ + public function detail(string $settleNo, ?int $merchantId = null): array + { + return $this->queryService->detail($settleNo, $merchantId); + } + + /** + * 清算入账成功。 + */ + public function completeSettlement(string $settleNo): SettlementOrder + { + return $this->lifecycleService->completeSettlement($settleNo); + } + + /** + * 清算失败。 + */ + public function failSettlement(string $settleNo, string $reason = ''): SettlementOrder + { + return $this->lifecycleService->failSettlement($settleNo, $reason); + } +} diff --git a/app/service/payment/trace/TradeTraceReportService.php b/app/service/payment/trace/TradeTraceReportService.php new file mode 100644 index 0000000..dedc75f --- /dev/null +++ b/app/service/payment/trace/TradeTraceReportService.php @@ -0,0 +1,248 @@ + $bizOrder !== null, + 'pay_order_count' => count($payOrders), + 'refund_order_count' => count($refundOrders), + 'settlement_order_count' => count($settlementOrders), + 'ledger_count' => count($accountLedgers), + 'callback_count' => count($payCallbacks), + 'pay_amount_total' => $this->sumBy($payOrders, 'pay_amount'), + 'refund_amount_total' => $this->sumBy($refundOrders, 'refund_amount'), + 'settlement_accounted_total' => $this->sumBy($settlementOrders, 'accounted_amount'), + 'ledger_amount_total' => $this->sumBy($accountLedgers, 'amount'), + ]; + } + + /** + * 根据关联记录组装追踪时间线。 + */ + public function buildTimeline(?BizOrder $bizOrder, array $payOrders, array $refundOrders, array $settlementOrders, array $accountLedgers, array $payCallbacks): array + { + $events = []; + $sortOrder = 0; + + if ($bizOrder) { + $this->pushEvent($events, $sortOrder, 'biz_order', (string) $bizOrder->biz_no, 'created', $bizOrder->created_at, [ + 'label' => '业务单创建', + 'status_text' => '创建', + 'biz_no' => (string) $bizOrder->biz_no, + 'merchant_order_no' => (string) $bizOrder->merchant_order_no, + ]); + $this->pushEvent($events, $sortOrder, 'biz_order', (string) $bizOrder->biz_no, 'paid', $bizOrder->paid_at, [ + 'label' => '业务单已支付', + 'status_text' => '成功', + 'biz_no' => (string) $bizOrder->biz_no, + ]); + $this->pushEvent($events, $sortOrder, 'biz_order', (string) $bizOrder->biz_no, 'closed', $bizOrder->closed_at, [ + 'label' => '业务单已关闭', + 'status_text' => '关闭', + 'biz_no' => (string) $bizOrder->biz_no, + ]); + $this->pushEvent($events, $sortOrder, 'biz_order', (string) $bizOrder->biz_no, 'failed', $bizOrder->failed_at, [ + 'label' => '业务单失败', + 'status_text' => '失败', + 'biz_no' => (string) $bizOrder->biz_no, + ]); + $this->pushEvent($events, $sortOrder, 'biz_order', (string) $bizOrder->biz_no, 'timeout', $bizOrder->timeout_at, [ + 'label' => '业务单超时', + 'status_text' => '超时', + 'biz_no' => (string) $bizOrder->biz_no, + ]); + } + + foreach ($payOrders as $payOrder) { + $payNo = (string) $payOrder->pay_no; + $statusTextMap = TradeConstant::orderStatusMap(); + $this->pushEvent($events, $sortOrder, 'pay_order', $payNo, 'created', $payOrder->request_at, [ + 'label' => '支付单创建', + 'status_text' => '创建', + 'pay_no' => $payNo, + 'biz_no' => (string) $payOrder->biz_no, + 'attempt_no' => (int) ($payOrder->attempt_no ?? 0), + ]); + $this->pushEvent($events, $sortOrder, 'pay_order', $payNo, 'paid', $payOrder->paid_at, [ + 'label' => '支付成功', + 'status_text' => (string) ($statusTextMap[TradeConstant::ORDER_STATUS_SUCCESS] ?? '成功'), + 'pay_no' => $payNo, + 'biz_no' => (string) $payOrder->biz_no, + 'channel_id' => (int) $payOrder->channel_id, + ]); + $this->pushEvent($events, $sortOrder, 'pay_order', $payNo, 'closed', $payOrder->closed_at, [ + 'label' => '支付关闭', + 'status_text' => (string) ($statusTextMap[TradeConstant::ORDER_STATUS_CLOSED] ?? '关闭'), + 'pay_no' => $payNo, + 'biz_no' => (string) $payOrder->biz_no, + ]); + $this->pushEvent($events, $sortOrder, 'pay_order', $payNo, 'failed', $payOrder->failed_at, [ + 'label' => '支付失败', + 'status_text' => (string) ($statusTextMap[TradeConstant::ORDER_STATUS_FAILED] ?? '失败'), + 'pay_no' => $payNo, + 'biz_no' => (string) $payOrder->biz_no, + 'channel_error_msg' => (string) ($payOrder->channel_error_msg ?? ''), + ]); + $this->pushEvent($events, $sortOrder, 'pay_order', $payNo, 'timeout', $payOrder->timeout_at, [ + 'label' => '支付超时', + 'status_text' => (string) ($statusTextMap[TradeConstant::ORDER_STATUS_TIMEOUT] ?? '超时'), + 'pay_no' => $payNo, + 'biz_no' => (string) $payOrder->biz_no, + ]); + } + + foreach ($refundOrders as $refundOrder) { + $refundNo = (string) $refundOrder->refund_no; + $statusTextMap = TradeConstant::refundStatusMap(); + $this->pushEvent($events, $sortOrder, 'refund_order', $refundNo, 'created', $refundOrder->request_at, [ + 'label' => '退款单创建', + 'status_text' => '创建', + 'refund_no' => $refundNo, + 'pay_no' => (string) $refundOrder->pay_no, + ]); + $this->pushEvent($events, $sortOrder, 'refund_order', $refundNo, 'processing', $refundOrder->processing_at, [ + 'label' => '退款处理中', + 'status_text' => (string) ($statusTextMap[TradeConstant::REFUND_STATUS_PROCESSING] ?? '处理中'), + 'refund_no' => $refundNo, + 'pay_no' => (string) $refundOrder->pay_no, + 'retry_count' => (int) ($refundOrder->retry_count ?? 0), + ]); + $this->pushEvent($events, $sortOrder, 'refund_order', $refundNo, 'success', $refundOrder->succeeded_at, [ + 'label' => '退款成功', + 'status_text' => (string) ($statusTextMap[TradeConstant::REFUND_STATUS_SUCCESS] ?? '成功'), + 'refund_no' => $refundNo, + 'pay_no' => (string) $refundOrder->pay_no, + ]); + $this->pushEvent($events, $sortOrder, 'refund_order', $refundNo, 'failed', $refundOrder->failed_at, [ + 'label' => '退款失败', + 'status_text' => (string) ($statusTextMap[TradeConstant::REFUND_STATUS_FAILED] ?? '失败'), + 'refund_no' => $refundNo, + 'pay_no' => (string) $refundOrder->pay_no, + 'last_error' => (string) ($refundOrder->last_error ?? ''), + ]); + } + + foreach ($settlementOrders as $settlementOrder) { + $settleNo = (string) $settlementOrder->settle_no; + $statusTextMap = TradeConstant::settlementStatusMap(); + $this->pushEvent($events, $sortOrder, 'settlement_order', $settleNo, 'generated', $settlementOrder->generated_at, [ + 'label' => '清结算单生成', + 'status_text' => '生成', + 'settle_no' => $settleNo, + ]); + $this->pushEvent($events, $sortOrder, 'settlement_order', $settleNo, 'accounted', $settlementOrder->accounted_at, [ + 'label' => '清结算入账', + 'status_text' => '入账', + 'settle_no' => $settleNo, + 'accounted_amount' => (int) ($settlementOrder->accounted_amount ?? 0), + ]); + $this->pushEvent($events, $sortOrder, 'settlement_order', $settleNo, 'completed', $settlementOrder->completed_at, [ + 'label' => '清结算完成', + 'status_text' => (string) ($statusTextMap[TradeConstant::SETTLEMENT_STATUS_SETTLED] ?? '已清算'), + 'settle_no' => $settleNo, + ]); + $this->pushEvent($events, $sortOrder, 'settlement_order', $settleNo, 'failed', $settlementOrder->failed_at, [ + 'label' => '清结算失败', + 'status_text' => (string) ($statusTextMap[TradeConstant::SETTLEMENT_STATUS_REVERSED] ?? '已冲正'), + 'settle_no' => $settleNo, + 'fail_reason' => (string) ($settlementOrder->fail_reason ?? ''), + ]); + } + + foreach ($accountLedgers as $ledger) { + $this->pushEvent($events, $sortOrder, 'ledger', (string) $ledger->ledger_no, 'recorded', $ledger->created_at, [ + 'label' => '资金流水', + 'status_text' => (string) (LedgerConstant::eventTypeMap()[$ledger->event_type] ?? '流水'), + 'ledger_no' => (string) $ledger->ledger_no, + 'biz_no' => (string) $ledger->biz_no, + 'biz_type' => (int) $ledger->biz_type, + 'biz_type_text' => (string) (LedgerConstant::bizTypeMap()[$ledger->biz_type] ?? ''), + 'direction' => (int) $ledger->direction, + 'direction_text' => (string) (LedgerConstant::directionMap()[$ledger->direction] ?? ''), + 'amount' => (int) $ledger->amount, + ]); + } + + foreach ($payCallbacks as $callback) { + $this->pushEvent($events, $sortOrder, 'pay_callback', (string) ($callback['pay_no'] ?? ''), 'received', $callback['created_at'] ?? null, [ + 'label' => '支付回调', + 'status_text' => (string) ($callback['callback_type_text'] ?? '回调'), + 'pay_no' => (string) ($callback['pay_no'] ?? ''), + 'channel_id' => (int) ($callback['channel_id'] ?? 0), + 'verify_status' => (int) ($callback['verify_status'] ?? 0), + 'verify_status_text' => (string) ($callback['verify_status_text'] ?? ''), + 'process_status' => (int) ($callback['process_status'] ?? 0), + 'process_status_text' => (string) ($callback['process_status_text'] ?? ''), + ]); + } + + usort($events, static function (array $left, array $right): int { + $cmp = strcmp((string) ($left['at'] ?? ''), (string) ($right['at'] ?? '')); + if ($cmp !== 0) { + return $cmp; + } + + return ($left['_sort_order'] ?? 0) <=> ($right['_sort_order'] ?? 0); + }); + + foreach ($events as &$event) { + unset($event['_sort_order']); + } + unset($event); + + return array_values($events); + } + + /** + * 追加一条时间线事件。 + */ + private function pushEvent(array &$events, int &$sortOrder, string $type, string $sourceNo, string $status, mixed $at, array $payload = []): void + { + $atText = $this->formatDateTime($at); + if ($atText === '') { + return; + } + + $events[] = [ + 'type' => $type, + 'source_no' => $sourceNo, + 'status' => $status, + 'status_text' => (string) ($payload['status_text'] ?? ''), + 'label' => (string) ($payload['label'] ?? ''), + 'at' => $atText, + 'payload' => $payload, + '_sort_order' => $sortOrder++, + ]; + } + + /** + * 汇总模型列表中的数值字段。 + */ + private function sumBy(array $items, string $field): int + { + $total = 0; + foreach ($items as $item) { + $total += (int) ($item->{$field} ?? 0); + } + + return $total; + } +} diff --git a/app/service/payment/trace/TradeTraceService.php b/app/service/payment/trace/TradeTraceService.php new file mode 100644 index 0000000..e2ea5d1 --- /dev/null +++ b/app/service/payment/trace/TradeTraceService.php @@ -0,0 +1,269 @@ +bizOrderRepository->findByTraceNo($traceNo); + if (!$bizOrder) { + $bizOrder = $this->bizOrderRepository->findByBizNo($traceNo); + if ($bizOrder) { + $matchedBy = 'biz_no'; + } + } + + $resolvedTraceNo = $traceNo; + if ($bizOrder) { + $resolvedTraceNo = (string) ($bizOrder->trace_no ?: $bizOrder->biz_no); + } + + $payOrders = $this->loadPayOrders($resolvedTraceNo, $bizOrder); + $refundOrders = $this->loadRefundOrders($resolvedTraceNo, $bizOrder); + $settlementOrders = $this->loadSettlementOrders($resolvedTraceNo); + + if (!$bizOrder) { + $bizOrder = $this->deriveBizOrder($payOrders, $refundOrders); + if ($bizOrder) { + $matchedBy = $matchedBy === 'trace_no' ? 'derived' : $matchedBy; + $resolvedTraceNo = (string) ($bizOrder->trace_no ?: $bizOrder->biz_no); + $payOrders = $this->loadPayOrders($resolvedTraceNo, $bizOrder); + $refundOrders = $this->loadRefundOrders($resolvedTraceNo, $bizOrder); + $settlementOrders = $this->loadSettlementOrders($resolvedTraceNo); + } + } + + $payCallbacks = $this->loadPayCallbacks($payOrders); + $accountLedgers = $this->loadLedgers($resolvedTraceNo, $bizOrder, $payOrders, $refundOrders, $settlementOrders); + if (empty($accountLedgers) && $resolvedTraceNo !== $traceNo) { + $accountLedgers = $this->loadLedgers($traceNo, $bizOrder, $payOrders, $refundOrders, $settlementOrders); + } + + if ( + $bizOrder === null + && empty($payOrders) + && empty($refundOrders) + && empty($settlementOrders) + && empty($accountLedgers) + && empty($payCallbacks) + ) { + return []; + } + + return [ + 'trace_no' => $traceNo, + 'resolved_trace_no' => $resolvedTraceNo, + 'matched_by' => $matchedBy, + 'biz_order' => $bizOrder, + 'pay_orders' => $payOrders, + 'refund_orders' => $refundOrders, + 'settlement_orders' => $settlementOrders, + 'account_ledgers' => $accountLedgers, + 'pay_callbacks' => $payCallbacks, + 'summary' => $this->tradeTraceReportService->buildSummary($bizOrder, $payOrders, $refundOrders, $settlementOrders, $accountLedgers, $payCallbacks), + 'timeline' => $this->tradeTraceReportService->buildTimeline($bizOrder, $payOrders, $refundOrders, $settlementOrders, $accountLedgers, $payCallbacks), + ]; + } + + /** + * 加载支付单列表。 + */ + private function loadPayOrders(string $traceNo, ?BizOrder $bizOrder): array + { + $items = $this->collectionToArray($this->payOrderRepository->listByTraceNo($traceNo)); + if (!empty($items)) { + return $items; + } + + if ($bizOrder) { + return $this->collectionToArray($this->payOrderRepository->listByBizNo((string) $bizOrder->biz_no)); + } + + return []; + } + + /** + * 加载退款单列表。 + */ + private function loadRefundOrders(string $traceNo, ?BizOrder $bizOrder): array + { + $items = $this->collectionToArray($this->refundOrderRepository->listByTraceNo($traceNo)); + if (!empty($items)) { + return $items; + } + + if ($bizOrder) { + return $this->collectionToArray($this->refundOrderRepository->listByBizNo((string) $bizOrder->biz_no)); + } + + return []; + } + + /** + * 加载清结算单列表。 + */ + private function loadSettlementOrders(string $traceNo): array + { + return $this->collectionToArray($this->settlementOrderRepository->listByTraceNo($traceNo)); + } + + /** + * 加载支付回调日志列表。 + */ + private function loadPayCallbacks(array $payOrders): array + { + $callbacks = []; + foreach ($payOrders as $payOrder) { + foreach ($this->payCallbackLogRepository->listByPayNo((string) $payOrder->pay_no) as $callback) { + $callbacks[] = [ + 'id' => (int) ($callback->id ?? 0), + 'pay_no' => (string) $callback->pay_no, + 'channel_id' => (int) $callback->channel_id, + 'callback_type' => (int) $callback->callback_type, + 'callback_type_text' => (string) (NotifyConstant::callbackTypeMap()[$callback->callback_type] ?? ''), + 'request_data' => $callback->request_data, + 'verify_status' => (int) $callback->verify_status, + 'verify_status_text' => (string) (NotifyConstant::verifyStatusMap()[$callback->verify_status] ?? ''), + 'process_status' => (int) $callback->process_status, + 'process_status_text' => (string) (NotifyConstant::processStatusMap()[$callback->process_status] ?? ''), + 'process_result' => $callback->process_result, + 'created_at' => $callback->created_at, + ]; + } + } + + usort($callbacks, static function ($left, $right): int { + return ($right['id'] ?? 0) <=> ($left['id'] ?? 0); + }); + + return $callbacks; + } + + /** + * 加载资金流水列表。 + */ + private function loadLedgers(string $traceNo, ?BizOrder $bizOrder, array $payOrders, array $refundOrders, array $settlementOrders): array + { + $ledgers = []; + $seen = []; + + foreach ($this->collectionToArray($this->merchantAccountLedgerRepository->listByTraceNo($traceNo)) as $ledger) { + $seen[(string) $ledger->ledger_no] = true; + $ledgers[] = $ledger; + } + + $bizNos = []; + if ($bizOrder) { + $bizNos[] = (string) $bizOrder->biz_no; + } + + foreach ($payOrders as $payOrder) { + $bizNos[] = (string) ($payOrder->pay_no ?? ''); + } + + foreach ($refundOrders as $refundOrder) { + $bizNos[] = (string) ($refundOrder->refund_no ?? ''); + } + + foreach ($settlementOrders as $settlementOrder) { + $bizNos[] = (string) ($settlementOrder->settle_no ?? ''); + } + + $bizNos = array_values(array_filter(array_unique($bizNos))); + foreach ($bizNos as $bizNo) { + foreach ($this->collectionToArray($this->merchantAccountLedgerRepository->listByBizNo($bizNo)) as $ledger) { + $ledgerNo = (string) ($ledger->ledger_no ?? ''); + if ($ledgerNo !== '' && isset($seen[$ledgerNo])) { + continue; + } + if ($ledgerNo !== '') { + $seen[$ledgerNo] = true; + } + $ledgers[] = $ledger; + } + } + + usort($ledgers, static function ($left, $right): int { + return ($right->id ?? 0) <=> ($left->id ?? 0); + }); + + return $ledgers; + } + + /** + * 从支付单或退款单反推出业务单。 + */ + private function deriveBizOrder(array $payOrders, array $refundOrders): ?BizOrder + { + if (!empty($payOrders)) { + $bizNo = (string) ($payOrders[0]->biz_no ?? ''); + if ($bizNo !== '') { + $bizOrder = $this->bizOrderRepository->findByBizNo($bizNo); + if ($bizOrder) { + return $bizOrder; + } + } + } + + if (!empty($refundOrders)) { + $bizNo = (string) ($refundOrders[0]->biz_no ?? ''); + if ($bizNo !== '') { + $bizOrder = $this->bizOrderRepository->findByBizNo($bizNo); + if ($bizOrder) { + return $bizOrder; + } + } + } + + return null; + } + + /** + * 将可迭代对象转换为普通数组。 + */ + private function collectionToArray(iterable $items): array + { + $rows = []; + foreach ($items as $item) { + $rows[] = $item; + } + + return $rows; + } + +} diff --git a/app/service/system/access/AdminAuthService.php b/app/service/system/access/AdminAuthService.php new file mode 100644 index 0000000..fa97bf2 --- /dev/null +++ b/app/service/system/access/AdminAuthService.php @@ -0,0 +1,113 @@ +jwtTokenManager->verify('admin', $token, $ip, $userAgent); + if ($result === null) { + return null; + } + + $adminId = (int) ($result['session']['admin_id'] ?? $result['claims']['sub'] ?? 0); + if ($adminId <= 0) { + return null; + } + + /** @var AdminUser|null $admin */ + $admin = $this->adminUserRepository->find($adminId); + if (!$admin || (int) $admin->status !== CommonConstant::STATUS_ENABLED) { + return null; + } + + return $admin; + } + + /** + * 校验管理员账号密码并签发 JWT。 + */ + public function authenticateCredentials(string $username, string $password, string $ip = '', string $userAgent = ''): array + { + $admin = $this->adminUserRepository->findByUsername($username); + if (!$admin || (int) $admin->status !== CommonConstant::STATUS_ENABLED) { + throw new ValidationException('管理员账号或密码错误'); + } + + if (!password_verify($password, (string) $admin->password_hash)) { + throw new ValidationException('管理员账号或密码错误'); + } + + $admin->last_login_at = $this->now(); + $admin->last_login_ip = $ip; + $admin->save(); + + return $this->issueToken((int) $admin->id, 86400, $ip, $userAgent); + } + + /** + * 撤销当前管理员登录 token。 + */ + public function revokeToken(string $token): bool + { + return $this->jwtTokenManager->revoke('admin', $token); + } + + /** + * 签发新的管理员登录 token。 + */ + public function issueToken(int $adminId, int $ttlSeconds = 86400, string $ip = '', string $userAgent = ''): array + { + /** @var AdminUser|null $admin */ + $admin = $this->adminUserRepository->find($adminId); + if (!$admin) { + throw new ValidationException('管理员不存在'); + } + + $issued = $this->jwtTokenManager->issue('admin', [ + 'sub' => (string) $adminId, + 'admin_id' => $adminId, + 'username' => (string) $admin->username, + 'is_super' => (int) $admin->is_super, + ], [ + 'admin_id' => $adminId, + 'admin_username' => (string) $admin->username, + 'real_name' => (string) $admin->real_name, + 'is_super' => (int) $admin->is_super, + 'last_login_ip' => $ip, + 'user_agent' => $userAgent, + ], $ttlSeconds); + + return [ + 'token' => $issued['token'], + 'expires_in' => $issued['expires_in'], + 'admin' => $admin, + ]; + } +} diff --git a/app/service/system/config/SystemConfigDefinitionService.php b/app/service/system/config/SystemConfigDefinitionService.php new file mode 100644 index 0000000..dce73c2 --- /dev/null +++ b/app/service/system/config/SystemConfigDefinitionService.php @@ -0,0 +1,242 @@ +tabCache !== null) { + return $this->tabCache; + } + + $definitions = (array) config('system_config', []); + $tabs = []; + $seenKeys = []; + $seenFields = []; + + foreach ($definitions as $groupCode => $definition) { + if (!is_array($definition)) { + continue; + } + + $tab = $this->normalizeTab((string) $groupCode, $definition); + if ($tab === null) { + continue; + } + + $key = $tab['key']; + if (isset($seenKeys[$key])) { + throw new RuntimeException(sprintf('系统配置标签 key 重复:%s', $key)); + } + + foreach ($tab['rules'] as $rule) { + $field = (string) ($rule['field'] ?? ''); + if ($field === '' || $this->isVirtualField($field)) { + continue; + } + + if (isset($seenFields[$field])) { + throw new RuntimeException(sprintf('系统配置项 key 重复:%s', $field)); + } + + $seenFields[$field] = true; + } + + $seenKeys[$key] = true; + $tabs[] = $tab; + } + + usort($tabs, static function (array $left, array $right): int { + $leftSort = (int) ($left['sort'] ?? 0); + $rightSort = (int) ($right['sort'] ?? 0); + + return $leftSort <=> $rightSort; + }); + + $this->tabCache = $tabs; + $this->tabMapCache = []; + + foreach ($tabs as $tab) { + $key = (string) ($tab['key'] ?? ''); + if ($key !== '') { + $this->tabMapCache[$key] = $tab; + } + } + + return $this->tabCache; + } + + public function tab(string $groupCode): ?array + { + $groupCode = strtolower(trim($groupCode)); + if ($groupCode === '') { + return null; + } + + $this->tabs(); + + return $this->tabMapCache[$groupCode] ?? null; + } + + public function hydrateRules(array $tab, array $values): array + { + $rules = []; + foreach ((array) ($tab['rules'] ?? []) as $rule) { + if (!is_array($rule)) { + continue; + } + + $field = (string) ($rule['field'] ?? ''); + if ($field === '') { + continue; + } + + if (!$this->isVirtualField($field)) { + $rule['value'] = array_key_exists($field, $values) ? $values[$field] : ($rule['value'] ?? ''); + } + $rules[] = $rule; + } + + return $rules; + } + + public function extractFormData(array $tab, array $values): array + { + $data = []; + foreach ((array) ($tab['rules'] ?? []) as $rule) { + if (!is_array($rule)) { + continue; + } + + $field = (string) ($rule['field'] ?? ''); + if ($field === '' || $this->isVirtualField($field)) { + continue; + } + + $data[$field] = array_key_exists($field, $values) ? $values[$field] : ($rule['value'] ?? ''); + } + + return $data; + } + + public function requiredFieldMessages(array $tab): array + { + $messages = []; + foreach ((array) ($tab['rules'] ?? []) as $rule) { + if (!is_array($rule)) { + continue; + } + + $field = strtolower(trim((string) ($rule['field'] ?? ''))); + if ($field === '' || $this->isVirtualField($field)) { + continue; + } + + foreach ((array) ($rule['validate'] ?? []) as $validateRule) { + if (!is_array($validateRule)) { + continue; + } + + if (!empty($validateRule['required'])) { + $messages[$field] = (string) ($validateRule['message'] ?? sprintf('%s 不能为空', (string) ($rule['title'] ?? $field))); + break; + } + } + } + + return $messages; + } + + private function normalizeTab(string $groupCode, array $definition): ?array + { + $key = strtolower(trim((string) ($definition['key'] ?? $groupCode))); + if ($key === '') { + return null; + } + + $rules = []; + foreach ((array) ($definition['rules'] ?? []) as $rule) { + $normalizedRule = $this->normalizeRule($rule); + if ($normalizedRule !== null) { + $rules[] = $normalizedRule; + } + } + + return [ + 'key' => $key, + 'title' => (string) ($definition['title'] ?? $key), + 'icon' => (string) ($definition['icon'] ?? ''), + 'description' => (string) ($definition['description'] ?? ''), + 'sort' => (int) ($definition['sort'] ?? 0), + 'disabled' => (bool) ($definition['disabled'] ?? false), + 'submitText' => (string) ($definition['submitText'] ?? '保存配置'), + 'refreshAfterSubmit' => (bool) ($definition['refreshAfterSubmit'] ?? true), + 'rules' => $rules, + ]; + } + + private function normalizeRule(mixed $rule): ?array + { + if (!is_array($rule)) { + return null; + } + + $field = strtolower(trim((string) ($rule['field'] ?? ''))); + if ($field === '') { + return null; + } + + $options = []; + foreach ((array) ($rule['options'] ?? []) as $option) { + if (!is_array($option)) { + continue; + } + + $options[] = [ + 'label' => (string) ($option['label'] ?? ''), + 'value' => (string) ($option['value'] ?? ''), + ]; + } + + $validate = []; + foreach ((array) ($rule['validate'] ?? []) as $validateRule) { + if (!is_array($validateRule)) { + continue; + } + + $validate[] = $validateRule; + } + + $normalized = $rule; + $normalized['type'] = (string) ($rule['type'] ?? 'input'); + $normalized['field'] = $field; + $normalized['title'] = (string) ($rule['title'] ?? $field); + $normalized['value'] = (string) ($rule['value'] ?? ''); + $normalized['props'] = is_array($rule['props'] ?? null) ? $rule['props'] : []; + $normalized['options'] = $options; + $normalized['validate'] = $validate; + + return $normalized; + } + + private function isVirtualField(string $field): bool + { + return str_starts_with($field, self::VIRTUAL_FIELD_PREFIX); + } +} diff --git a/app/service/system/config/SystemConfigPageService.php b/app/service/system/config/SystemConfigPageService.php new file mode 100644 index 0000000..1d95e3f --- /dev/null +++ b/app/service/system/config/SystemConfigPageService.php @@ -0,0 +1,160 @@ +systemConfigDefinitionService->tabs() as $tab) { + unset($tab['rules']); + $tabs[] = $tab; + } + + $defaultKey = ''; + foreach ($tabs as $tab) { + if (!empty($tab['disabled'])) { + continue; + } + + $defaultKey = (string) ($tab['key'] ?? ''); + if ($defaultKey !== '') { + break; + } + } + + return [ + 'defaultKey' => $defaultKey !== '' ? $defaultKey : (string) ($tabs[0]['key'] ?? ''), + 'tabs' => $tabs, + ]; + } + + public function detail(string $groupCode): array + { + $tab = $this->systemConfigDefinitionService->tab($groupCode); + if (!$tab) { + throw new ValidationException('系统配置标签不存在'); + } + + $keys = []; + foreach ((array) ($tab['rules'] ?? []) as $rule) { + if (!is_array($rule)) { + continue; + } + + $field = strtolower(trim((string) ($rule['field'] ?? ''))); + if ($field !== '' && !str_starts_with($field, '__')) { + $keys[] = $field; + } + } + + $keys = array_values(array_unique($keys)); + if ($keys === []) { + $rowMap = []; + } else { + $rows = $this->systemConfigRepository->query() + ->whereIn('config_key', $keys) + ->get(['config_key', 'config_value']); + + $rowMap = []; + foreach ($rows as $row) { + $rowMap[strtolower((string) $row->config_key)] = (string) ($row->config_value ?? ''); + } + } + + $tab['rules'] = $this->systemConfigDefinitionService->hydrateRules($tab, $rowMap); + $tab['formData'] = $this->systemConfigDefinitionService->extractFormData($tab, $rowMap); + + return $tab; + } + + public function save(string $groupCode, array $values): array + { + $tab = $this->systemConfigDefinitionService->tab($groupCode); + if (!$tab) { + throw new ValidationException('系统配置标签不存在'); + } + + $formData = $this->systemConfigDefinitionService->extractFormData($tab, $values); + $this->validateRequiredValues($tab, $formData); + + $this->transaction(function () use ($tab, $formData): void { + foreach ((array) ($tab['rules'] ?? []) as $rule) { + if (!is_array($rule)) { + continue; + } + + $field = strtolower(trim((string) ($rule['field'] ?? ''))); + if ($field === '' || str_starts_with($field, '__')) { + continue; + } + + $value = $this->stringifyValue($formData[$field] ?? ''); + $this->systemConfigRepository->updateOrCreate( + ['config_key' => $field], + [ + 'group_code' => (string) $tab['key'], + 'config_value' => $value, + ] + ); + } + }); + + Event::emit('system.config.changed', [ + 'group_code' => (string) $tab['key'], + ]); + + return $this->detail((string) $tab['key']); + } + + protected function validateRequiredValues(array $tab, array $values): void + { + $messages = $this->systemConfigDefinitionService->requiredFieldMessages($tab); + + foreach ($messages as $field => $message) { + $value = $values[$field] ?? ''; + if ($this->isEmptyValue($value)) { + throw new ValidationException($message); + } + } + } + + protected function isEmptyValue(mixed $value): bool + { + if (is_array($value)) { + return $value === []; + } + + if (is_string($value)) { + return trim($value) === ''; + } + + return $value === null || $value === ''; + } + + protected function stringifyValue(mixed $value): string + { + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + if (is_array($value) || is_object($value)) { + throw new ValidationException('系统配置值暂不支持复杂类型'); + } + + return (string) $value; + } + +} diff --git a/app/service/system/config/SystemConfigRuntimeService.php b/app/service/system/config/SystemConfigRuntimeService.php new file mode 100644 index 0000000..7490c89 --- /dev/null +++ b/app/service/system/config/SystemConfigRuntimeService.php @@ -0,0 +1,124 @@ +readCache(); + if ($cached !== null) { + return $cached; + } + } + + return $this->refresh(); + } + + public function get(string $configKey, mixed $default = '', bool $refresh = false): string + { + $configKey = strtolower(trim($configKey)); + if ($configKey === '') { + return (string) $default; + } + + $values = $this->all($refresh); + + return (string) ($values[$configKey] ?? $default); + } + + public function refresh(): array + { + $values = $this->buildValueMap(); + $this->writeCache($values); + + return $values; + } + + protected function buildValueMap(): array + { + $values = []; + $tabs = $this->systemConfigDefinitionService->tabs(); + $keys = []; + + foreach ($tabs as $tab) { + foreach ((array) ($tab['rules'] ?? []) as $rule) { + if (!is_array($rule)) { + continue; + } + + $field = strtolower(trim((string) ($rule['field'] ?? ''))); + if ($field !== '' && !str_starts_with($field, '__')) { + $keys[] = $field; + } + } + } + + $keys = array_values(array_unique($keys)); + if ($keys === []) { + return []; + } + + $rows = $this->systemConfigRepository->query() + ->whereIn('config_key', $keys) + ->get(['config_key', 'config_value']); + + $rowMap = []; + foreach ($rows as $row) { + $rowMap[strtolower((string) $row->config_key)] = (string) ($row->config_value ?? ''); + } + + foreach ($tabs as $tab) { + foreach ((array) ($tab['rules'] ?? []) as $rule) { + if (!is_array($rule)) { + continue; + } + + $field = strtolower(trim((string) ($rule['field'] ?? ''))); + if ($field === '' || str_starts_with($field, '__')) { + continue; + } + + $values[$field] = array_key_exists($field, $rowMap) + ? (string) $rowMap[$field] + : (string) ($rule['value'] ?? ''); + } + } + + return $values; + } + + protected function readCache(): ?array + { + try { + $raw = Cache::get(self::CACHE_KEY); + } catch (Throwable) { + return null; + } + + return is_array($raw) ? $raw : null; + } + + protected function writeCache(array $values): void + { + try { + Cache::set(self::CACHE_KEY, $values); + } catch (Throwable) { + // Redis 不可用时不阻塞主流程。 + } + } +} diff --git a/app/service/system/user/AdminUserService.php b/app/service/system/user/AdminUserService.php new file mode 100644 index 0000000..e0f4939 --- /dev/null +++ b/app/service/system/user/AdminUserService.php @@ -0,0 +1,196 @@ +adminUserRepository->query()->from('ma_admin_user as u'); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('u.username', 'like', '%' . $keyword . '%') + ->orWhere('u.real_name', 'like', '%' . $keyword . '%') + ->orWhere('u.mobile', 'like', '%' . $keyword . '%') + ->orWhere('u.email', 'like', '%' . $keyword . '%'); + }); + } + + $status = (string) ($filters['status'] ?? ''); + if ($status !== '') { + $query->where('u.status', (int) $status); + } + + $isSuper = (string) ($filters['is_super'] ?? ''); + if ($isSuper !== '') { + $query->where('u.is_super', (int) $isSuper); + } + + $paginator = $query + ->select([ + 'u.id', + 'u.username', + 'u.real_name', + 'u.mobile', + 'u.email', + 'u.is_super', + 'u.status', + 'u.last_login_at', + 'u.last_login_ip', + 'u.remark', + 'u.created_at', + 'u.updated_at', + ]) + ->orderByDesc('u.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + $paginator->getCollection()->transform(function ($row) { + $row->status_text = (string) ((int) $row->status === CommonConstant::STATUS_ENABLED ? '启用' : '禁用'); + $row->is_super_text = (string) ((int) $row->is_super === 1 ? '超级管理员' : '普通管理员'); + + return $row; + }); + + return $paginator; + } + + /** + * 根据 ID 查询管理员用户。 + */ + public function findById(int $id): ?AdminUser + { + return $this->adminUserRepository->find($id); + } + + /** + * 新增管理员用户。 + */ + public function create(array $data): AdminUser + { + return $this->adminUserRepository->create($this->normalizePayload($data, false)); + } + + /** + * 修改管理员用户。 + */ + public function update(int $id, array $data): ?AdminUser + { + $current = $this->adminUserRepository->find($id); + if (!$current) { + return null; + } + + if (!$this->adminUserRepository->updateById($id, $this->normalizePayload($data, true))) { + return null; + } + + return $this->adminUserRepository->find($id); + } + + /** + * 删除管理员用户。 + */ + public function delete(int $id): bool + { + return $this->adminUserRepository->deleteById($id); + } + + /** + * 当前管理员资料。 + */ + public function profile(int $adminId, string $adminUsername = ''): array + { + $admin = $this->adminUserRepository->find($adminId); + if (!$admin) { + throw new ResourceNotFoundException('管理员不存在', ['admin_id' => $adminId]); + } + + $isSuper = (int) $admin->is_super === 1; + $role = [ + 'code' => 'admin', + 'name' => $isSuper ? '超级管理员' : '普通管理员', + 'admin' => $isSuper, + 'disabled' => false, + ]; + + $user = [ + 'id' => (int) $admin->id, + 'deptId' => '0', + 'deptName' => '管理中心', + 'userName' => (string) ($admin->username !== '' ? $admin->username : trim($adminUsername)), + 'nickName' => (string) ($admin->real_name !== '' ? $admin->real_name : $admin->username), + 'email' => (string) ($admin->email ?? ''), + 'phone' => (string) ($admin->mobile ?? ''), + 'sex' => 2, + 'avatar' => '', + 'status' => (int) $admin->status, + 'description' => trim((string) ($admin->remark ?? '')) !== '' ? (string) $admin->remark : '平台后台管理员账号', + 'roles' => [$role], + 'loginIp' => (string) ($admin->last_login_ip ?? ''), + 'loginDate' => $this->formatDateTime($admin->last_login_at ?? null), + 'createBy' => '系统', + 'createTime' => $this->formatDateTime($admin->created_at ?? null), + 'updateBy' => null, + 'updateTime' => $this->formatDateTime($admin->updated_at ?? null), + 'admin' => $isSuper, + ]; + + return [ + 'admin_id' => (int) $admin->id, + 'admin_username' => (string) ($admin->username !== '' ? $admin->username : trim($adminUsername)), + 'user' => $user, + 'roles' => ['admin'], + 'permissions' => $isSuper ? ['*:*:*'] : [], + ]; + } + + /** + * 统一整理写入字段,并处理密码哈希。 + */ + private function normalizePayload(array $data, bool $isUpdate): array + { + $payload = [ + 'username' => trim((string) ($data['username'] ?? '')), + 'real_name' => trim((string) ($data['real_name'] ?? '')), + 'mobile' => trim((string) ($data['mobile'] ?? '')), + 'email' => trim((string) ($data['email'] ?? '')), + 'is_super' => (int) ($data['is_super'] ?? 0), + 'status' => (int) ($data['status'] ?? CommonConstant::STATUS_ENABLED), + 'remark' => trim((string) ($data['remark'] ?? '')), + ]; + + $password = trim((string) ($data['password'] ?? '')); + if ($password !== '') { + $payload['password_hash'] = password_hash($password, PASSWORD_DEFAULT); + } elseif (!$isUpdate) { + $payload['password_hash'] = ''; + } + + return $payload; + } + +} diff --git a/app/services/AdminService.php b/app/services/AdminService.php deleted file mode 100644 index 5a87cc5..0000000 --- a/app/services/AdminService.php +++ /dev/null @@ -1,37 +0,0 @@ - array, 'roles' => array, 'permissions' => array] - */ - public function getInfoById(int $id): array - { - $admin = $this->adminRepository->find($id); - if (!$admin) { - throw new NotFoundException('管理员不存在'); - } - - return [ - 'user' => $admin->toArray(), - 'roles' => ['admin'], - 'permissions' => ['*:*:*'], - ]; - } -} diff --git a/app/services/AuthService.php b/app/services/AuthService.php deleted file mode 100644 index 0e149f2..0000000 --- a/app/services/AuthService.php +++ /dev/null @@ -1,96 +0,0 @@ - string] - */ - public function login(string $username, string $password, string $verifyCode, string $captchaId): array - { - if (!$this->captchaService->validate($captchaId, $verifyCode)) { - throw new BadRequestException('验证码错误或已失效'); - } - - $admin = $this->adminRepository->findByUserName($username); - if (!$admin) { - throw new UnauthorizedException('账号或密码错误'); - } - - if (!$this->validatePassword($password, $admin->password)) { - throw new UnauthorizedException('账号或密码错误'); - } - - if ($admin->status !== 1) { - throw new ForbiddenException('账号已被禁用'); - } - - $token = $this->generateToken($admin); - $this->cacheToken($token, $admin->id); - $this->updateLoginInfo($admin); - - return ['token' => $token]; - } - - private function validatePassword(string $password, ?string $hash): bool - { - if ($hash === null || $hash === '') { - return in_array($password, ['123456'], true); - } - return password_verify($password, $hash); - } - - private function generateToken(Admin $admin): string - { - $payload = [ - 'user_id' => $admin->id, - 'user_name' => $admin->user_name, - 'nick_name' => $admin->nick_name, - ]; - return JwtUtil::generateToken($payload); - } - - private function cacheToken(string $token, int $adminId): void - { - $key = JwtUtil::getCachePrefix() . $token; - $data = ['user_id' => $adminId, 'created_at' => time()]; - Cache::set($key, $data, JwtUtil::getTtl()); - } - - private function updateLoginInfo(Admin $admin): void - { - $request = request(); - $ip = $request->header('x-real-ip', '') - ?: ($request->header('x-forwarded-for', '') ? trim(explode(',', $request->header('x-forwarded-for', ''))[0]) : '') - ?: $request->getRemoteIp(); - $admin->login_ip = trim($ip); - $admin->login_at = date('Y-m-d H:i:s'); - $admin->save(); - } -} - diff --git a/app/services/CaptchaService.php b/app/services/CaptchaService.php deleted file mode 100644 index 4c69ecb..0000000 --- a/app/services/CaptchaService.php +++ /dev/null @@ -1,101 +0,0 @@ - string, 'image' => string] - */ - public function generate(): array - { - // 使用 webman/captcha 生成验证码图片和文本 - $builder = new CaptchaBuilder; - // 适配前端登录表单尺寸:110x30 - $builder->build(110, 30); - - $code = strtolower($builder->getPhrase()); - $id = bin2hex(random_bytes(16)); - - $payload = [ - 'code' => $code, - 'created_at' => time(), - 'error_times' => 0, - 'used' => false, - ]; - - Cache::set($this->buildKey($id), $payload, self::EXPIRE_SECONDS); - - // 获取图片二进制并转为 base64 - $imgContent = $builder->get(); - $base64 = base64_encode($imgContent ?: ''); - - return [ - 'captchaId' => $id, - 'image' => 'data:image/jpeg;base64,' . $base64, - ]; - } - - /** - * 校验验证码(基于 captchaId + code) - * - * @param string|null $id 验证码ID - * @param string|null $code 用户输入的验证码 - * @return bool - */ - public function validate(?string $id, ?string $code): bool - { - if ($id === null || $id === '' || $code === null || $code === '') { - return false; - } - - $key = $this->buildKey($id); - $data = Cache::get($key); - if (!$data || !is_array($data)) { - return false; - } - - // 已使用或错误次数过多 - if (!empty($data['used']) || ($data['error_times'] ?? 0) >= self::MAX_ERROR_TIMES) { - Cache::delete($key); - return false; - } - - $expect = (string)($data['code'] ?? ''); - if ($expect === '' || strtolower($code) !== strtolower($expect)) { - $data['error_times'] = ($data['error_times'] ?? 0) + 1; - Cache::set($key, $data, self::EXPIRE_SECONDS); - return false; - } - - // 标记为已使用,防重放 - $data['used'] = true; - Cache::set($key, $data, self::EXPIRE_SECONDS); - - return true; - } - - /** - * 构建缓存键 - */ - private function buildKey(string $id): string - { - return self::CACHE_PREFIX . $id; - } -} - diff --git a/app/services/ChannelRoutePolicyService.php b/app/services/ChannelRoutePolicyService.php deleted file mode 100644 index 4dbd4be..0000000 --- a/app/services/ChannelRoutePolicyService.php +++ /dev/null @@ -1,103 +0,0 @@ -configService->getValue(self::CONFIG_KEY, '[]'); - - if (is_array($raw)) { - $policies = $raw; - } else { - $decoded = json_decode((string)$raw, true); - $policies = is_array($decoded) ? $decoded : []; - } - - usort($policies, function (array $left, array $right) { - return strcmp((string)($right['updated_at'] ?? ''), (string)($left['updated_at'] ?? '')); - }); - - return $policies; - } - - public function save(array $policyData): array - { - $policies = $this->list(); - $id = trim((string)($policyData['id'] ?? '')); - $now = date('Y-m-d H:i:s'); - - $stored = [ - 'id' => $id !== '' ? $id : $this->generateId(), - 'policy_name' => trim((string)($policyData['policy_name'] ?? '')), - 'merchant_id' => (int)($policyData['merchant_id'] ?? 0), - 'merchant_app_id' => (int)($policyData['merchant_app_id'] ?? 0), - 'method_code' => trim((string)($policyData['method_code'] ?? '')), - 'plugin_code' => trim((string)($policyData['plugin_code'] ?? '')), - 'route_mode' => trim((string)($policyData['route_mode'] ?? 'priority')), - 'status' => (int)($policyData['status'] ?? 1), - 'circuit_breaker_threshold' => max(0, min(100, (int)($policyData['circuit_breaker_threshold'] ?? 50))), - 'failover_cooldown' => max(0, (int)($policyData['failover_cooldown'] ?? 10)), - 'remark' => trim((string)($policyData['remark'] ?? '')), - 'items' => array_values($policyData['items'] ?? []), - 'updated_at' => $now, - ]; - - $found = false; - foreach ($policies as &$policy) { - if (($policy['id'] ?? '') !== $stored['id']) { - continue; - } - - $stored['created_at'] = $policy['created_at'] ?? $now; - $policy = $stored; - $found = true; - break; - } - unset($policy); - - if (!$found) { - $stored['created_at'] = $now; - $policies[] = $stored; - } - - $this->configService->setValue(self::CONFIG_KEY, $policies); - - return $stored; - } - - public function delete(string $id): bool - { - $id = trim($id); - if ($id === '') { - return false; - } - - $policies = $this->list(); - $filtered = array_values(array_filter($policies, function (array $policy) use ($id) { - return ($policy['id'] ?? '') !== $id; - })); - - if (count($filtered) === count($policies)) { - return false; - } - - $this->configService->setValue(self::CONFIG_KEY, $filtered); - return true; - } - - private function generateId(): string - { - return 'rp_' . date('YmdHis') . mt_rand(1000, 9999); - } -} diff --git a/app/services/ChannelRouterService.php b/app/services/ChannelRouterService.php deleted file mode 100644 index d5bd3fb..0000000 --- a/app/services/ChannelRouterService.php +++ /dev/null @@ -1,613 +0,0 @@ -chooseChannelWithDecision($merchantId, $merchantAppId, $methodId, $amount); - return $decision['channel']; - } - - /** - * 返回完整路由决策信息,便于下单链路记录调度痕迹。 - * - * @return array{ - * channel:PaymentChannel, - * source:string, - * route_mode:string, - * policy:?array, - * candidates:array> - * } - */ - public function chooseChannelWithDecision(int $merchantId, int $merchantAppId, int $methodId, float $amount = 0): array - { - $routingContext = $this->loadRoutingContexts($merchantId, $merchantAppId, $methodId, $amount); - $method = $routingContext['method']; - $contexts = $routingContext['contexts']; - - $decision = $this->chooseByPolicy( - $merchantId, - $merchantAppId, - (string)$method->method_code, - $contexts - ); - - if ($decision !== null) { - return $decision; - } - - $decision = $this->chooseFallback($contexts); - if ($decision !== null) { - return $decision; - } - - throw new NotFoundException( - $this->buildNoChannelMessage($merchantId, $merchantAppId, (string)$method->method_name, $contexts) - ); - } - - /** - * 预览一个尚未保存的策略草稿在当前真实通道环境下会如何命中。 - */ - public function previewPolicyDraft( - int $merchantId, - int $merchantAppId, - int $methodId, - array $policy, - float $amount = 0 - ): array { - $routingContext = $this->loadRoutingContexts($merchantId, $merchantAppId, $methodId, $amount); - $method = $routingContext['method']; - $contexts = $routingContext['contexts']; - $previewPolicy = $this->normalizePreviewPolicy($policy, $merchantId, $merchantAppId, (string)$method->method_code); - $evaluation = $this->evaluatePolicy($previewPolicy, $contexts); - - $selectedChannel = null; - if ($evaluation['selected_candidate'] !== null) { - $selectedContext = $contexts[(int)$evaluation['selected_candidate']['channel_id']] ?? null; - if ($selectedContext !== null) { - /** @var PaymentChannel $channel */ - $channel = $selectedContext['channel']; - $selectedChannel = [ - 'id' => (int)$channel->id, - 'chan_code' => (string)$channel->chan_code, - 'chan_name' => (string)$channel->chan_name, - ]; - } - } - - return [ - 'matched' => $selectedChannel !== null, - 'source' => 'preview', - 'route_mode' => (string)($previewPolicy['route_mode'] ?? 'priority'), - 'policy' => $this->buildPolicyMeta($previewPolicy), - 'selected_channel' => $selectedChannel, - 'candidates' => $evaluation['candidates'], - 'summary' => [ - 'candidate_count' => count($evaluation['candidates']), - 'available_count' => count($evaluation['available_candidates']), - 'blocked_count' => count($evaluation['candidates']) - count($evaluation['available_candidates']), - ], - 'message' => $selectedChannel !== null ? '本次模拟已命中策略通道' : '当前策略下没有可用通道', - ]; - } - - private function chooseByPolicy(int $merchantId, int $merchantAppId, string $methodCode, array $contexts): ?array - { - return $this->chooseByPolicies( - $merchantId, - $merchantAppId, - $methodCode, - $contexts, - $this->routePolicyService->list() - ); - } - - private function chooseFallback(array $contexts): ?array - { - $candidates = []; - foreach ($contexts as $context) { - /** @var PaymentChannel $channel */ - $channel = $context['channel']; - $candidates[] = [ - 'channel_id' => (int)$channel->id, - 'chan_code' => (string)$channel->chan_code, - 'chan_name' => (string)$channel->chan_name, - 'available' => $context['available'], - 'reasons' => $context['reasons'], - 'priority' => (int)$channel->sort, - 'weight' => 100, - 'role' => 'normal', - 'health_score' => $context['health_score'], - 'success_rate' => $context['success_rate'], - ]; - } - - $availableCandidates = array_values(array_filter($candidates, fn(array $item) => (bool)$item['available'])); - if ($availableCandidates === []) { - return null; - } - - usort($availableCandidates, function (array $left, array $right) { - if (($left['priority'] ?? 0) === ($right['priority'] ?? 0)) { - if (($right['health_score'] ?? 0) === ($left['health_score'] ?? 0)) { - return ($right['success_rate'] ?? 0) <=> ($left['success_rate'] ?? 0); - } - return ($right['health_score'] ?? 0) <=> ($left['health_score'] ?? 0); - } - return ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0); - }); - - $selectedCandidate = $availableCandidates[0]; - $selectedContext = $contexts[(int)$selectedCandidate['channel_id']] ?? null; - if (!$selectedContext) { - return null; - } - - return [ - 'channel' => $selectedContext['channel'], - 'source' => 'fallback', - 'route_mode' => 'sort', - 'policy' => null, - 'candidates' => $candidates, - ]; - } - - private function loadRoutingContexts(int $merchantId, int $merchantAppId, int $methodId, float $amount): array - { - $method = $this->methodRepository->find($methodId); - if (!$method) { - throw new NotFoundException("未找到支付方式:{$methodId}"); - } - - $channels = $this->channelRepository->searchList([ - 'merchant_id' => $merchantId, - 'merchant_app_id' => $merchantAppId, - 'method_id' => $methodId, - ]); - - if ($channels->isEmpty()) { - throw new NotFoundException( - "未找到可用的支付通道:商户ID={$merchantId}, 应用ID={$merchantAppId}, 支付方式ID={$methodId}" - ); - } - - $todayRange = $this->getDateRange(1); - $recentRange = $this->getDateRange(self::HEALTH_LOOKBACK_DAYS); - $channelIds = []; - foreach ($channels as $channel) { - $channelIds[] = (int)$channel->id; - } - - $todayStatsMap = $this->orderRepository->aggregateByChannel($channelIds, [ - 'merchant_id' => $merchantId, - 'merchant_app_id' => $merchantAppId, - 'method_id' => $methodId, - 'created_from' => $todayRange['created_from'], - 'created_to' => $todayRange['created_to'], - ]); - $recentStatsMap = $this->orderRepository->aggregateByChannel($channelIds, [ - 'merchant_id' => $merchantId, - 'merchant_app_id' => $merchantAppId, - 'method_id' => $methodId, - 'created_from' => $recentRange['created_from'], - 'created_to' => $recentRange['created_to'], - ]); - - $contexts = []; - foreach ($channels as $channel) { - $contexts[(int)$channel->id] = $this->buildChannelContext( - $channel, - $todayStatsMap[(int)$channel->id] ?? [], - $recentStatsMap[(int)$channel->id] ?? [], - $amount - ); - } - - return [ - 'method' => $method, - 'contexts' => $contexts, - ]; - } - - private function buildChannelContext(PaymentChannel $channel, array $todayStats, array $recentStats, float $amount): array - { - $reasons = []; - $status = (int)$channel->status; - - $todayOrders = (int)($todayStats['total_orders'] ?? 0); - $todaySuccessAmount = round((float)($todayStats['success_amount'] ?? 0), 2); - $recentTotalOrders = (int)($recentStats['total_orders'] ?? 0); - $recentSuccessOrders = (int)($recentStats['success_orders'] ?? 0); - $recentPendingOrders = (int)($recentStats['pending_orders'] ?? 0); - $recentFailOrders = (int)($recentStats['fail_orders'] ?? 0); - - $dailyLimit = (float)$channel->daily_limit; - $dailyCount = (int)$channel->daily_cnt; - $minAmount = $channel->min_amount === null ? null : (float)$channel->min_amount; - $maxAmount = $channel->max_amount === null ? null : (float)$channel->max_amount; - - if ($status !== 1) { - $reasons[] = '通道已禁用'; - } - if ($amount > 0 && $minAmount !== null && $amount < $minAmount) { - $reasons[] = '低于最小支付金额'; - } - if ($amount > 0 && $maxAmount !== null && $maxAmount > 0 && $amount > $maxAmount) { - $reasons[] = '超过最大支付金额'; - } - if ($dailyLimit > 0 && $todaySuccessAmount + max(0, $amount) > $dailyLimit) { - $reasons[] = '超出单日限额'; - } - if ($dailyCount > 0 && $todayOrders + 1 > $dailyCount) { - $reasons[] = '超出单日笔数限制'; - } - - $successRate = $recentTotalOrders > 0 ? round($recentSuccessOrders / $recentTotalOrders * 100, 2) : 0; - $dailyLimitUsageRate = $dailyLimit > 0 ? round(min(100, ($todaySuccessAmount / $dailyLimit) * 100), 2) : null; - $healthScore = $this->calculateHealthScore( - $status, - $recentTotalOrders, - $recentSuccessOrders, - $recentPendingOrders, - $recentFailOrders, - $dailyLimitUsageRate - ); - - return [ - 'channel' => $channel, - 'available' => $reasons === [], - 'reasons' => $reasons, - 'success_rate' => $successRate, - 'health_score' => $healthScore, - 'today_orders' => $todayOrders, - 'today_success_amount' => $todaySuccessAmount, - ]; - } - - private function calculateHealthScore( - int $status, - int $totalOrders, - int $successOrders, - int $pendingOrders, - int $failOrders, - ?float $todayLimitUsageRate - ): int { - if ($status !== 1) { - return 0; - } - - if ($totalOrders === 0) { - return 60; - } - - $successRate = $totalOrders > 0 ? ($successOrders / $totalOrders * 100) : 0; - $healthScore = 90; - - if ($successRate < 95) { - $healthScore -= 10; - } - if ($successRate < 80) { - $healthScore -= 15; - } - if ($successRate < 60) { - $healthScore -= 20; - } - if ($failOrders > 0) { - $healthScore -= min(15, $failOrders * 3); - } - if ($pendingOrders > max(3, (int)floor($successOrders / 2))) { - $healthScore -= 10; - } - if ($todayLimitUsageRate !== null && $todayLimitUsageRate >= 90) { - $healthScore -= 20; - } elseif ($todayLimitUsageRate !== null && $todayLimitUsageRate >= 75) { - $healthScore -= 10; - } - - return max(0, min(100, $healthScore)); - } - - private function chooseByPolicies( - int $merchantId, - int $merchantAppId, - string $methodCode, - array $contexts, - array $policies - ): ?array { - $matchedPolicies = array_values(array_filter($policies, function (array $policy) use ( - $merchantId, - $merchantAppId, - $methodCode - ) { - if ((int)($policy['status'] ?? 0) !== 1) { - return false; - } - if (($policy['method_code'] ?? '') !== $methodCode) { - return false; - } - - $policyMerchantId = (int)($policy['merchant_id'] ?? 0); - if ($policyMerchantId > 0 && $policyMerchantId !== $merchantId) { - return false; - } - - $policyAppId = (int)($policy['merchant_app_id'] ?? 0); - if ($policyAppId > 0 && $policyAppId !== $merchantAppId) { - return false; - } - - return is_array($policy['items'] ?? null) && $policy['items'] !== []; - })); - - if ($matchedPolicies === []) { - return null; - } - - usort($matchedPolicies, function (array $left, array $right) { - $leftScore = $this->calculatePolicySpecificity($left); - $rightScore = $this->calculatePolicySpecificity($right); - if ($leftScore === $rightScore) { - return strcmp((string)($right['updated_at'] ?? ''), (string)($left['updated_at'] ?? '')); - } - return $rightScore <=> $leftScore; - }); - - foreach ($matchedPolicies as $policy) { - $evaluation = $this->evaluatePolicy($policy, $contexts); - if ($evaluation['selected_candidate'] === null) { - continue; - } - - $selectedContext = $contexts[(int)$evaluation['selected_candidate']['channel_id']] ?? null; - if (!$selectedContext) { - continue; - } - - return [ - 'channel' => $selectedContext['channel'], - 'source' => 'policy', - 'route_mode' => (string)($policy['route_mode'] ?? 'priority'), - 'policy' => $this->buildPolicyMeta($policy), - 'candidates' => $evaluation['candidates'], - ]; - } - - return null; - } - - private function normalizePolicyItems(array $items): array - { - $normalized = []; - foreach ($items as $index => $item) { - $normalized[] = [ - 'channel_id' => (int)($item['channel_id'] ?? 0), - 'role' => (string)($item['role'] ?? ($index === 0 ? 'primary' : 'backup')), - 'weight' => max(0, (int)($item['weight'] ?? 100)), - 'priority' => max(1, (int)($item['priority'] ?? ($index + 1))), - ]; - } - - usort($normalized, function (array $left, array $right) { - if (($left['priority'] ?? 0) === ($right['priority'] ?? 0)) { - if (($left['role'] ?? '') === ($right['role'] ?? '')) { - return ($right['weight'] ?? 0) <=> ($left['weight'] ?? 0); - } - - return ($left['role'] ?? '') === 'primary' ? -1 : 1; - } - - return ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0); - }); - - return $normalized; - } - - private function evaluatePolicy(array $policy, array $contexts): array - { - $items = $this->normalizePolicyItems($policy['items'] ?? []); - $candidates = []; - - foreach ($items as $item) { - $channelId = (int)($item['channel_id'] ?? 0); - $context = $contexts[$channelId] ?? null; - if (!$context) { - $candidates[] = [ - 'channel_id' => $channelId, - 'chan_code' => '', - 'chan_name' => '', - 'available' => false, - 'reasons' => ['通道不存在或不属于当前应用'], - 'priority' => (int)($item['priority'] ?? 1), - 'weight' => (int)($item['weight'] ?? 100), - 'role' => (string)($item['role'] ?? 'backup'), - 'health_score' => 0, - 'success_rate' => 0, - ]; - continue; - } - - /** @var PaymentChannel $channel */ - $channel = $context['channel']; - $pluginCode = trim((string)($policy['plugin_code'] ?? '')); - $policyReasons = []; - if ($pluginCode !== '' && (string)$channel->plugin_code !== $pluginCode) { - $policyReasons[] = '插件与策略限定不匹配'; - } - - $available = $context['available'] && $policyReasons === []; - $candidates[] = [ - 'channel_id' => (int)$channel->id, - 'chan_code' => (string)$channel->chan_code, - 'chan_name' => (string)$channel->chan_name, - 'available' => $available, - 'reasons' => $available ? [] : array_values(array_unique(array_merge($context['reasons'], $policyReasons))), - 'priority' => (int)($item['priority'] ?? 1), - 'weight' => (int)($item['weight'] ?? 100), - 'role' => (string)($item['role'] ?? 'backup'), - 'health_score' => $context['health_score'], - 'success_rate' => $context['success_rate'], - ]; - } - - $availableCandidates = array_values(array_filter($candidates, fn(array $item) => (bool)$item['available'])); - $selectedCandidate = $availableCandidates === [] - ? null - : $this->pickCandidateByMode($availableCandidates, (string)($policy['route_mode'] ?? 'priority')); - - return [ - 'candidates' => $candidates, - 'available_candidates' => $availableCandidates, - 'selected_candidate' => $selectedCandidate, - ]; - } - - private function pickCandidateByMode(array $candidates, string $routeMode): array - { - usort($candidates, function (array $left, array $right) { - if (($left['priority'] ?? 0) === ($right['priority'] ?? 0)) { - if (($left['role'] ?? '') === ($right['role'] ?? '')) { - return ($right['weight'] ?? 0) <=> ($left['weight'] ?? 0); - } - - return ($left['role'] ?? '') === 'primary' ? -1 : 1; - } - - return ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0); - }); - - if ($routeMode !== 'weight') { - return $candidates[0]; - } - - $totalWeight = 0; - foreach ($candidates as $candidate) { - $totalWeight += max(0, (int)($candidate['weight'] ?? 0)); - } - - if ($totalWeight <= 0) { - return $candidates[0]; - } - - $cursor = mt_rand(1, $totalWeight); - foreach ($candidates as $candidate) { - $cursor -= max(0, (int)($candidate['weight'] ?? 0)); - if ($cursor <= 0) { - return $candidate; - } - } - - return $candidates[0]; - } - - private function calculatePolicySpecificity(array $policy): int - { - $score = 0; - if ((int)($policy['merchant_id'] ?? 0) > 0) { - $score += 10; - } - if ((int)($policy['merchant_app_id'] ?? 0) > 0) { - $score += 20; - } - if (trim((string)($policy['plugin_code'] ?? '')) !== '') { - $score += 5; - } - - return $score; - } - - private function buildPolicyMeta(array $policy): array - { - return [ - 'id' => (string)($policy['id'] ?? ''), - 'policy_name' => (string)($policy['policy_name'] ?? ''), - 'plugin_code' => (string)($policy['plugin_code'] ?? ''), - 'circuit_breaker_threshold' => (int)($policy['circuit_breaker_threshold'] ?? 0), - 'failover_cooldown' => (int)($policy['failover_cooldown'] ?? 0), - ]; - } - - private function buildNoChannelMessage(int $merchantId, int $merchantAppId, string $methodName, array $contexts): string - { - $messages = []; - foreach ($contexts as $context) { - /** @var PaymentChannel $channel */ - $channel = $context['channel']; - $reasonText = $context['reasons'] === [] ? '无可用原因记录' : implode('、', $context['reasons']); - $messages[] = sprintf('%s(%s):%s', (string)$channel->chan_name, (string)$channel->chan_code, $reasonText); - } - - usort($messages, fn(string $left, string $right) => strcmp($left, $right)); - $messages = array_slice($messages, 0, 3); - - $suffix = $messages === [] ? '' : ',原因:' . implode(';', $messages); - - return sprintf( - '未找到可用的支付通道:商户ID=%d,应用ID=%d,支付方式=%s%s', - $merchantId, - $merchantAppId, - $methodName, - $suffix - ); - } - - private function getDateRange(int $days): array - { - $days = max(1, $days); - return [ - 'created_from' => date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')), - 'created_to' => date('Y-m-d H:i:s'), - ]; - } - - private function normalizePreviewPolicy(array $policy, int $merchantId, int $merchantAppId, string $methodCode): array - { - $routeMode = trim((string)($policy['route_mode'] ?? 'priority')); - if (!in_array($routeMode, ['priority', 'weight', 'failover'], true)) { - $routeMode = 'priority'; - } - - return [ - 'id' => trim((string)($policy['id'] ?? 'preview_policy')), - 'policy_name' => trim((string)($policy['policy_name'] ?? '策略草稿')), - 'merchant_id' => $merchantId, - 'merchant_app_id' => $merchantAppId, - 'method_code' => $methodCode, - 'plugin_code' => trim((string)($policy['plugin_code'] ?? '')), - 'route_mode' => $routeMode, - 'status' => 1, - 'circuit_breaker_threshold' => max(0, min(100, (int)($policy['circuit_breaker_threshold'] ?? 50))), - 'failover_cooldown' => max(0, (int)($policy['failover_cooldown'] ?? 10)), - 'items' => $this->normalizePolicyItems($policy['items'] ?? []), - 'updated_at' => date('Y-m-d H:i:s'), - ]; - } -} diff --git a/app/services/MenuService.php b/app/services/MenuService.php deleted file mode 100644 index c43b52b..0000000 --- a/app/services/MenuService.php +++ /dev/null @@ -1,89 +0,0 @@ -getSystemMenu(); - return $this->buildMenuTree($menus); - } - - /** - * 获取系统菜单数据 - * 仅从 JSON 文件 + 缓存中读取 - */ - protected function getSystemMenu(): array - { - $menus = Cache::get(self::CACHE_KEY_MENU); - - if (!is_array($menus)) { - // 优先读取 JSON 文件 - $jsonPath = config_path('system-file/menu.json'); - if (!file_exists($jsonPath)) { - throw new InternalServerException('菜单配置文件不存在'); - } - - $jsonContent = file_get_contents($jsonPath); - $data = json_decode($jsonContent, true); - - if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { - throw new InternalServerException('菜单配置文件格式错误:' . json_last_error_msg()); - } - - $menus = $data; - Cache::set(self::CACHE_KEY_MENU, $menus); - } - - return $menus; - } - - /** - * 构建菜单树形结构 - */ - protected function buildMenuTree(array $menus, string $parentId = '0'): array - { - $tree = []; - - foreach ($menus as $menu) { - if (($menu['parentId'] ?? '0') === $parentId) { - $children = $this->buildMenuTree($menus, $menu['id']); - $menu['children'] = !empty($children) ? $children : null; - $tree[] = $menu; - } - } - - // 按 sort 排序 - usort($tree, function ($a, $b) { - return ($a['meta']['sort'] ?? 0) <=> ($b['meta']['sort'] ?? 0); - }); - - return $tree; - } -} - - diff --git a/app/services/NotifyService.php b/app/services/NotifyService.php deleted file mode 100644 index 149e4a6..0000000 --- a/app/services/NotifyService.php +++ /dev/null @@ -1,121 +0,0 @@ -orderRepository->findByOrderId($orderId); - if (!$order) { - return; - } - - $existing = $this->notifyTaskRepository->findByOrderId($orderId); - if ($existing) { - return; - } - - $notifyUrl = $order->extra['notify_url'] ?? ''; - if (empty($notifyUrl)) { - Log::warning('订单缺少 notify_url,跳过创建通知任务', ['order_id' => $orderId]); - return; - } - - $this->notifyTaskRepository->create([ - 'order_id' => $orderId, - 'merchant_id' => $order->merchant_id, - 'merchant_app_id' => $order->merchant_app_id, - 'notify_url' => $notifyUrl, - 'notify_data' => json_encode([ - 'order_id' => $order->order_id, - 'mch_order_no' => $order->mch_order_no, - 'status' => $order->status, - 'amount' => $order->amount, - 'pay_time' => $order->pay_at, - ], JSON_UNESCAPED_UNICODE), - 'status' => PaymentNotifyTask::STATUS_PENDING, - 'retry_cnt' => 0, - 'next_retry_at' => date('Y-m-d H:i:s'), - ]); - } - - /** - * 发送通知 - */ - public function sendNotify(PaymentNotifyTask $task): bool - { - try { - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $task->notify_url); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, $task->notify_data); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - ]); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - $success = ($httpCode === 200 && strtolower(trim($response)) === 'success'); - - $this->notifyTaskRepository->updateById($task->id, [ - 'status' => $success ? PaymentNotifyTask::STATUS_SUCCESS : PaymentNotifyTask::STATUS_PENDING, - 'retry_cnt' => $task->retry_cnt + 1, - 'last_notify_at' => date('Y-m-d H:i:s'), - 'last_response' => $response, - 'next_retry_at' => $success ? null : $this->calculateNextRetryTime($task->retry_cnt + 1), - ]); - - return $success; - } catch (\Throwable $e) { - Log::error('发送通知失败', [ - 'task_id' => $task->id, - 'error' => $e->getMessage(), - ]); - - $this->notifyTaskRepository->updateById($task->id, [ - 'retry_cnt' => $task->retry_cnt + 1, - 'last_notify_at' => date('Y-m-d H:i:s'), - 'last_response' => $e->getMessage(), - 'next_retry_at' => $this->calculateNextRetryTime($task->retry_cnt + 1), - ]); - - return false; - } - } - - /** - * 计算下次重试时间(指数退避) - */ - private function calculateNextRetryTime(int $retryCount): string - { - $intervals = [60, 300, 900, 3600]; // 1分钟、5分钟、15分钟、1小时 - $interval = $intervals[min($retryCount - 1, count($intervals) - 1)] ?? 3600; - return date('Y-m-d H:i:s', time() + $interval); - } -} - diff --git a/app/services/PayNotifyService.php b/app/services/PayNotifyService.php deleted file mode 100644 index 2582d88..0000000 --- a/app/services/PayNotifyService.php +++ /dev/null @@ -1,199 +0,0 @@ - 幂等 -> 更新订单 -> 创建商户通知任务。 - */ -class PayNotifyService extends BaseService -{ - public function __construct( - protected PluginService $pluginService, - protected PaymentStateService $paymentStateService, - protected CallbackInboxRepository $callbackInboxRepository, - protected PaymentChannelRepository $channelRepository, - protected PaymentCallbackLogRepository $callbackLogRepository, - protected PaymentOrderRepository $orderRepository, - protected NotifyService $notifyService, - ) { - } - - /** - * @return array{ok:bool,already?:bool,msg:string,order_id?:string} - */ - public function handleNotify(string $pluginCode, Request $request): array - { - $rawPayload = array_merge($request->get(), $request->post()); - $candidateOrderId = $this->extractOrderIdFromPayload($rawPayload); - $order = $candidateOrderId !== '' ? $this->orderRepository->findByOrderId($candidateOrderId) : null; - - try { - $plugin = $this->pluginService->getPluginInstance($pluginCode); - - // 验签前初始化插件配置,保证如支付宝证书验签等能力可用。 - if ($order && (int)$order->channel_id > 0) { - $channel = $this->channelRepository->find((int)$order->channel_id); - if ($channel) { - if ((string)$channel->plugin_code !== $pluginCode) { - return ['ok' => false, 'msg' => 'plugin mismatch']; - } - $channelConfig = array_merge( - $channel->getConfigArray(), - ['enabled_products' => $channel->getEnabledProducts()] - ); - $plugin->init($channelConfig); - } - } - - $notifyData = $plugin->notify($request); - } catch (\Throwable $e) { - $this->callbackLogRepository->createLog([ - 'order_id' => $candidateOrderId, - 'channel_id' => $order ? (int)$order->channel_id : 0, - 'callback_type' => 'notify', - 'request_data' => json_encode($rawPayload, JSON_UNESCAPED_UNICODE), - 'verify_status' => 0, - 'process_status' => 0, - 'process_result' => $e->getMessage(), - ]); - return ['ok' => false, 'msg' => 'verify failed']; - } - - $orderId = (string)($notifyData['pay_order_id'] ?? ''); - $status = strtolower((string)($notifyData['status'] ?? '')); - $chanTradeNo = (string)($notifyData['chan_trade_no'] ?? ''); - - if ($orderId === '') { - return ['ok' => false, 'msg' => 'missing pay_order_id']; - } - - // 已验签但状态非 success 时,也走状态机进行失败态收敛。 - if ($status !== 'success') { - $order = $this->orderRepository->findByOrderId($orderId); - if ($order) { - try { - $this->paymentStateService->markFailed($order); - } catch (\Throwable $e) { - // 非法迁移不影响回调日志记录 - } - } - - $this->callbackLogRepository->createLog([ - 'order_id' => $orderId, - 'channel_id' => $order ? (int)$order->channel_id : 0, - 'callback_type' => 'notify', - 'request_data' => json_encode($rawPayload, JSON_UNESCAPED_UNICODE), - 'verify_status' => 1, - 'process_status' => 0, - 'process_result' => 'notify status is not success', - ]); - - return ['ok' => false, 'msg' => 'notify status is not success']; - } - - $eventKey = $this->buildEventKey($pluginCode, $orderId, $chanTradeNo, $notifyData); - $payload = $rawPayload; - - $inserted = $this->callbackInboxRepository->createIfAbsent([ - 'event_key' => $eventKey, - 'plugin_code' => $pluginCode, - 'order_id' => $orderId, - 'chan_trade_no' => $chanTradeNo, - 'payload' => $payload, - 'process_status' => 0, - 'processed_at' => null, - ]); - - if (!$inserted) { - return ['ok' => true, 'already' => true, 'msg' => 'success', 'order_id' => $orderId]; - } - - $order = $this->orderRepository->findByOrderId($orderId); - if (!$order) { - $this->callbackLogRepository->createLog([ - 'order_id' => $orderId, - 'channel_id' => 0, - 'callback_type' => 'notify', - 'request_data' => json_encode($payload, JSON_UNESCAPED_UNICODE), - 'verify_status' => 1, - 'process_status' => 0, - 'process_result' => 'order not found', - ]); - - return ['ok' => false, 'msg' => 'order not found']; - } - - try { - $this->transaction(function () use ($order, $chanTradeNo, $payload, $pluginCode) { - $this->paymentStateService->markPaid($order, $chanTradeNo); - - $this->callbackLogRepository->createLog([ - 'order_id' => $order->order_id, - 'channel_id' => (int)$order->channel_id, - 'callback_type' => 'notify', - 'request_data' => json_encode($payload, JSON_UNESCAPED_UNICODE), - 'verify_status' => 1, - 'process_status' => 1, - 'process_result' => 'success:' . $pluginCode, - ]); - - $this->notifyService->createNotifyTask($order->order_id); - }); - } catch (\Throwable $e) { - $this->callbackLogRepository->createLog([ - 'order_id' => $order->order_id, - 'channel_id' => (int)$order->channel_id, - 'callback_type' => 'notify', - 'request_data' => json_encode($payload, JSON_UNESCAPED_UNICODE), - 'verify_status' => 1, - 'process_status' => 0, - 'process_result' => $e->getMessage(), - ]); - return ['ok' => false, 'msg' => 'process failed']; - } - - $event = $this->callbackInboxRepository->findByEventKey($eventKey); - if ($event) { - $this->callbackInboxRepository->updateById((int)$event->id, [ - 'process_status' => 1, - 'processed_at' => date('Y-m-d H:i:s'), - ]); - } - - return ['ok' => true, 'msg' => 'success', 'order_id' => $orderId]; - } - - private function buildEventKey(string $pluginCode, string $orderId, string $chanTradeNo, array $notifyData): string - { - $base = $pluginCode . '|' . $orderId . '|' . $chanTradeNo . '|' . ($notifyData['status'] ?? ''); - return sha1($base); - } - - private function extractOrderIdFromPayload(array $payload): string - { - $candidates = [ - $payload['pay_order_id'] ?? null, - $payload['order_id'] ?? null, - $payload['out_trade_no'] ?? null, - $payload['trade_no'] ?? null, - ]; - - foreach ($candidates as $id) { - $value = trim((string)$id); - if ($value !== '') { - return $value; - } - } - - return ''; - } -} diff --git a/app/services/PayOrderService.php b/app/services/PayOrderService.php deleted file mode 100644 index b793f1c..0000000 --- a/app/services/PayOrderService.php +++ /dev/null @@ -1,189 +0,0 @@ -methodRepository->findByCode($payType); - if (!$method) { - throw new BadRequestException('支付方式不存在'); - } - - // 3. 幂等校验:同一商户应用下相同商户订单号只保留一条 - // 先查一次(减少异常成本),并发场景再用唯一键冲突兜底 - $existing = $this->orderRepository->findByMchNo($mchId, $appId, $mchNo); - if ($existing) { - return $existing; - } - - // 4. 生成系统订单号 - $orderId = $this->generateOrderId(); - $amount = sprintf('%.2f', $amountFloat); - - // 5. 创建订单 - $expireTime = (int)sys_config('order_expire_time', 0); // 0 表示不设置过期时间 - try { - return $this->orderRepository->create([ - 'order_id' => $orderId, - 'merchant_id' => $mchId, - 'merchant_app_id' => $appId, - 'mch_order_no' => $mchNo, - 'method_id' => $method->id, - 'amount' => $amount, - 'real_amount' => $amount, - 'subject' => $subject, - 'body' => $data['body'] ?? $subject, - 'status' => PaymentOrder::STATUS_PENDING, - 'client_ip' => $data['client_ip'] ?? '', - 'expire_at' => $expireTime > 0 ? date('Y-m-d H:i:s', time() + $expireTime) : null, - 'extra' => $data['extra'] ?? [], - ]); - } catch (QueryException $e) { - // 并发场景:唯一键 uk_mch_order 冲突时回查返回已有订单 - $existing = $this->orderRepository->findByMchNo($mchId, $appId, $mchNo); - if ($existing) { - return $existing; - } - throw $e; - } - } - - /** - * 订单退款(供易支付等接口调用) - * - * @param array $data - * - order_id: 系统订单号(必填) - * - refund_amount: 退款金额(必填) - * - refund_reason: 退款原因(可选) - * @return array - */ - public function refundOrder(array $data): array - { - $orderId = (string)($data['order_id'] ?? $data['pay_order_id'] ?? ''); - $refundAmount = (float)($data['refund_amount'] ?? 0); - - if ($orderId === '') { - throw new BadRequestException('订单号不能为空'); - } - if ($refundAmount <= 0) { - throw new BadRequestException('退款金额必须大于0'); - } - - // 1. 查询订单 - $order = $this->orderRepository->findByOrderId($orderId); - if (!$order) { - throw new NotFoundException('订单不存在'); - } - - // 2. 验证订单状态 - if ($order->status !== PaymentOrder::STATUS_SUCCESS) { - throw new BadRequestException('订单状态不允许退款'); - } - - // 3. 验证退款金额 - if ($refundAmount > $order->amount) { - throw new BadRequestException('退款金额不能大于订单金额'); - } - - // 4. 查询通道 - $channel = $this->channelRepository->find($order->channel_id); - if (!$channel) { - throw new NotFoundException('支付通道不存在'); - } - - // 5. 查询支付方式 - $method = $this->methodRepository->find($order->method_id); - if (!$method) { - throw new NotFoundException('支付方式不存在'); - } - - // 6. 实例化插件并初始化(通过插件服务) - $plugin = $this->pluginService->getPluginInstance($channel->plugin_code); - - $channelConfig = array_merge( - $channel->getConfigArray(), - ['enabled_products' => $channel->getEnabledProducts()] - ); - $plugin->init($channelConfig); - - // 7. 调用插件退款 - $refundData = [ - 'order_id' => $order->order_id, - 'chan_order_no' => $order->chan_order_no, - 'chan_trade_no' => $order->chan_trade_no, - 'refund_amount' => $refundAmount, - 'refund_reason' => $data['refund_reason'] ?? '', - ]; - - $refundResult = $plugin->refund($refundData); - - // 8. 如果是全额退款则关闭订单 - if ($refundAmount >= $order->amount) { - $this->paymentStateService->closeAfterFullRefund($order, $refundResult); - } - - return [ - 'order_id' => $order->order_id, - 'refund_amount' => $refundAmount, - 'refund_result' => $refundResult, - ]; - } - - /** - * 生成支付订单号 - */ - private function generateOrderId(): string - { - return 'P' . date('YmdHis') . mt_rand(100000, 999999); - } -} diff --git a/app/services/PayService.php b/app/services/PayService.php deleted file mode 100644 index f1933e9..0000000 --- a/app/services/PayService.php +++ /dev/null @@ -1,213 +0,0 @@ -payOrderService->createOrder($orderData); - $extra = $order->extra ?? []; - - // 2. 查询支付方式 - $method = $this->methodRepository->find($order->method_id); - if (!$method) { - throw new NotFoundException('支付方式不存在'); - } - - // 3. 通道路由 - try { - $routeDecision = $this->channelRouterService->chooseChannelWithDecision( - (int)$order->merchant_id, - (int)$order->merchant_app_id, - (int)$order->method_id, - (float)$order->amount - ); - } catch (\Throwable $e) { - $extra['route_error'] = [ - 'message' => $e->getMessage(), - 'at' => date('Y-m-d H:i:s'), - ]; - $this->orderRepository->updateById((int)$order->id, ['extra' => $extra]); - throw $e; - } - - /** @var \app\models\PaymentChannel $channel */ - $channel = $routeDecision['channel']; - unset($extra['route_error']); - $extra['routing'] = $this->buildRoutingSnapshot($routeDecision, $channel); - $this->orderRepository->updateById((int)$order->id, [ - 'channel_id' => (int)$channel->id, - 'extra' => $extra, - ]); - - // 4. 实例化插件并初始化(通过插件服务) - $plugin = $this->pluginService->getPluginInstance($channel->plugin_code); - - $channelConfig = array_merge( - $channel->getConfigArray(), - ['enabled_products' => $channel->getEnabledProducts()] - ); - $plugin->init($channelConfig); - - // 5. 环境检测 - $device = $options['device'] ?? ''; - /** @var Request|null $request */ - $request = $options['request'] ?? null; - - if ($device) { - $env = $this->mapDeviceToEnv($device); - } elseif ($request instanceof Request) { - $env = $this->detectEnvironment($request); - } else { - $env = 'pc'; - } - - // 6. 调用插件统一下单 - $pluginOrderData = [ - 'order_id' => $order->order_id, - 'mch_no' => $order->mch_order_no, - 'amount' => $order->amount, - 'subject' => $order->subject, - 'body' => $order->body, - 'extra' => $extra, - '_env' => $env, - ]; - - $payResult = $plugin->pay($pluginOrderData); - - // 7. 计算实际支付金额(扣除手续费) - $amount = (float)$order->amount; - $chanCost = (float)$channel->chan_cost; - $fee = ((float)$order->fee) > 0 ? (float)$order->fee : round($amount * ($chanCost / 100), 2); - $realAmount = round($amount - $fee, 2); - - // 8. 更新订单(通道、支付参数、实际金额) - $extra['pay_params'] = $payResult['pay_params'] ?? null; - $chanOrderNo = $payResult['chan_order_no'] ?? $payResult['channel_order_no'] ?? ''; - $chanTradeNo = $payResult['chan_trade_no'] ?? $payResult['channel_trade_no'] ?? ''; - - $this->orderRepository->updateById($order->id, [ - 'channel_id' => $channel->id, - 'chan_order_no' => $chanOrderNo, - 'chan_trade_no' => $chanTradeNo, - 'real_amount' => sprintf('%.2f', $realAmount), - 'fee' => sprintf('%.2f', $fee), - 'extra' => $extra, - ]); - - return [ - 'order_id' => $order->order_id, - 'mch_no' => $order->mch_order_no, - 'pay_params' => $payResult['pay_params'] ?? null, - ]; - } - - private function buildRoutingSnapshot(array $routeDecision, \app\models\PaymentChannel $channel): array - { - $policy = is_array($routeDecision['policy'] ?? null) ? $routeDecision['policy'] : null; - $candidates = []; - foreach (($routeDecision['candidates'] ?? []) as $candidate) { - $candidates[] = [ - 'channel_id' => (int)($candidate['channel_id'] ?? 0), - 'chan_code' => (string)($candidate['chan_code'] ?? ''), - 'chan_name' => (string)($candidate['chan_name'] ?? ''), - 'available' => (bool)($candidate['available'] ?? false), - 'priority' => (int)($candidate['priority'] ?? 0), - 'weight' => (int)($candidate['weight'] ?? 0), - 'role' => (string)($candidate['role'] ?? ''), - 'reasons' => array_values($candidate['reasons'] ?? []), - ]; - } - - return [ - 'source' => (string)($routeDecision['source'] ?? 'fallback'), - 'route_mode' => (string)($routeDecision['route_mode'] ?? 'sort'), - 'policy' => $policy, - 'selected_channel_id' => (int)$channel->id, - 'selected_channel_code' => (string)$channel->chan_code, - 'selected_channel_name' => (string)$channel->chan_name, - 'candidates' => array_slice($candidates, 0, 10), - 'selected_at' => date('Y-m-d H:i:s'), - ]; - } - - /** - * 根据请求 UA 检测环境 - */ - private function detectEnvironment(Request $request): string - { - $ua = strtolower($request->header('User-Agent', '')); - - if (strpos($ua, 'alipayclient') !== false) { - return 'alipay'; - } - - if (strpos($ua, 'micromessenger') !== false) { - return 'wechat'; - } - - $mobileKeywords = ['mobile', 'android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone']; - foreach ($mobileKeywords as $keyword) { - if (strpos($ua, $keyword) !== false) { - return 'h5'; - } - } - - return 'pc'; - } - - /** - * 映射设备类型到环境代码 - */ - private function mapDeviceToEnv(string $device): string - { - $mapping = [ - 'pc' => 'pc', - 'mobile' => 'h5', - 'qq' => 'h5', - 'wechat' => 'wechat', - 'alipay' => 'alipay', - 'jump' => 'pc', - ]; - - return $mapping[strtolower($device)] ?? 'pc'; - } -} - - diff --git a/app/services/PaymentStateService.php b/app/services/PaymentStateService.php deleted file mode 100644 index 858ddbb..0000000 --- a/app/services/PaymentStateService.php +++ /dev/null @@ -1,113 +0,0 @@ - SUCCESS/FAIL/CLOSED - * - SUCCESS -> CLOSED - */ -class PaymentStateService extends BaseService -{ - public function __construct( - protected PaymentOrderRepository $orderRepository - ) { - } - - /** - * 回调支付成功。 - * - * @return bool true=状态有变更, false=幂等无变更 - */ - public function markPaid(PaymentOrder $order, string $chanTradeNo = '', ?string $payAt = null): bool - { - $from = (int)$order->status; - if ($from === PaymentOrder::STATUS_SUCCESS) { - return false; - } - if (!$this->canTransit($from, PaymentOrder::STATUS_SUCCESS)) { - throw new BadRequestException("illegal status transition: {$from} -> " . PaymentOrder::STATUS_SUCCESS); - } - - $ok = $this->orderRepository->updateById((int)$order->id, [ - 'status' => PaymentOrder::STATUS_SUCCESS, - 'pay_at' => $payAt ?: date('Y-m-d H:i:s'), - 'chan_trade_no' => $chanTradeNo !== '' ? $chanTradeNo : (string)$order->chan_trade_no, - ]); - - return (bool)$ok; - } - - /** - * 标记支付失败(用于已验签的失败回调)。 - * - * @return bool true=状态有变更, false=幂等无变更 - */ - public function markFailed(PaymentOrder $order): bool - { - $from = (int)$order->status; - if ($from === PaymentOrder::STATUS_FAIL) { - return false; - } - if (!$this->canTransit($from, PaymentOrder::STATUS_FAIL)) { - throw new BadRequestException("illegal status transition: {$from} -> " . PaymentOrder::STATUS_FAIL); - } - - $ok = $this->orderRepository->updateById((int)$order->id, [ - 'status' => PaymentOrder::STATUS_FAIL, - ]); - - return (bool)$ok; - } - - /** - * 全额退款后关单。 - * - * @return bool true=状态有变更, false=幂等无变更 - */ - public function closeAfterFullRefund(PaymentOrder $order, array $refundInfo = []): bool - { - $from = (int)$order->status; - if ($from === PaymentOrder::STATUS_CLOSED) { - return false; - } - if (!$this->canTransit($from, PaymentOrder::STATUS_CLOSED)) { - throw new BadRequestException("illegal status transition: {$from} -> " . PaymentOrder::STATUS_CLOSED); - } - - $extra = $order->extra ?? []; - $extra['refund_info'] = $refundInfo; - - $ok = $this->orderRepository->updateById((int)$order->id, [ - 'status' => PaymentOrder::STATUS_CLOSED, - 'extra' => $extra, - ]); - - return (bool)$ok; - } - - private function canTransit(int $from, int $to): bool - { - $allowed = [ - PaymentOrder::STATUS_PENDING => [ - PaymentOrder::STATUS_SUCCESS, - PaymentOrder::STATUS_FAIL, - PaymentOrder::STATUS_CLOSED, - ], - PaymentOrder::STATUS_SUCCESS => [ - PaymentOrder::STATUS_CLOSED, - ], - PaymentOrder::STATUS_FAIL => [], - PaymentOrder::STATUS_CLOSED => [], - ]; - - return in_array($to, $allowed[$from] ?? [], true); - } -} diff --git a/app/services/PluginService.php b/app/services/PluginService.php deleted file mode 100644 index 656ec4d..0000000 --- a/app/services/PluginService.php +++ /dev/null @@ -1,169 +0,0 @@ - - */ - public function listPlugins(): array - { - $rows = $this->pluginRepository->getActivePlugins(); - - $plugins = []; - foreach ($rows as $row) { - $pluginCode = $row->code; - - $pluginName = (string)($row->name ?? ''); - $supportedMethods = is_array($row->pay_types ?? null) ? (array)$row->pay_types : []; - - // 如果数据库里缺少元信息,则回退到实例化插件并写回数据库 - if ($pluginName === '' || $supportedMethods === []) { - try { - $plugin = $this->resolvePlugin($pluginCode, (string)($row->class_name ?? '')); - $this->syncPluginMeta($pluginCode, $plugin); - $pluginName = $plugin->getName(); - $supportedMethods = (array)$plugin->getEnabledPayTypes(); - } catch (\Throwable $e) { - // 忽略无法实例化的插件 - continue; - } - } - - $plugins[] = [ - 'code' => $pluginCode, - 'name' => $pluginName, - 'supported_methods' => $supportedMethods, - ]; - } - - return $plugins; - } - - /** - * 获取插件配置 Schema - */ - public function getConfigSchema(string $pluginCode, string $methodCode): array - { - $row = $this->pluginRepository->findActiveByCode($pluginCode); - if ($row && is_array($row->config_schema ?? null) && $row->config_schema !== []) { - return (array)$row->config_schema; - } - - $plugin = $this->getPluginInstance($pluginCode); - $schema = (array)$plugin->getConfigSchema(); - $this->syncPluginMeta($pluginCode, $plugin); - return $schema; - } - - /** - * 获取插件支持的支付产品列表 - */ - public function getSupportedProducts(string $pluginCode, string $methodCode): array - { - /** @var mixed $plugin */ - $plugin = $this->getPluginInstance($pluginCode); - if (method_exists($plugin, 'getSupportedProducts')) { - return (array)$plugin->getSupportedProducts($methodCode); - } - return []; - } - - /** - * 从表单数据中提取插件配置参数(根据插件 Schema) - */ - public function buildConfigFromForm(string $pluginCode, string $methodCode, array $formData): array - { - $configSchema = $this->getConfigSchema($pluginCode, $methodCode); - - $configJson = []; - if (isset($configSchema['fields']) && is_array($configSchema['fields'])) { - foreach ($configSchema['fields'] as $field) { - $fieldName = $field['field'] ?? ''; - if ($fieldName && array_key_exists($fieldName, $formData)) { - $configJson[$fieldName] = $formData[$fieldName]; - } - } - } - - return $configJson; - } - - /** - * 对外统一提供:根据插件编码获取插件实例 - */ - public function getPluginInstance(string $pluginCode): PaymentInterface&PayPluginInterface - { - $row = $this->pluginRepository->findActiveByCode($pluginCode); - if (!$row) { - throw new NotFoundException('支付插件未注册或已禁用:' . $pluginCode); - } - - return $this->resolvePlugin($pluginCode, $row->class_name); - } - - /** - * 根据插件编码和 class_name 解析并实例化插件 - */ - private function resolvePlugin(string $pluginCode, ?string $className = null): PaymentInterface&PayPluginInterface - { - $class = $className ?: (ucfirst($pluginCode) . 'Payment'); - // 允许 DB 中只存短类名(如 AlipayPayment),这里统一补全命名空间 - if ($class !== '' && !str_contains($class, '\\')) { - $class = 'app\\common\\payment\\' . $class; - } - - if (!class_exists($class)) { - throw new NotFoundException('支付插件类不存在:' . $class); - } - - $plugin = new $class(); - if (!$plugin instanceof PaymentInterface || !$plugin instanceof PayPluginInterface) { - throw new NotFoundException('支付插件类型错误:' . $class); - } - - return $plugin; - } - - /** - * 把插件元信息写回数据库,供“列表/Schema 直接从DB读取” - */ - private function syncPluginMeta(string $pluginCode, PaymentInterface&PayPluginInterface $plugin): void - { - $payTypes = (array)$plugin->getEnabledPayTypes(); - $transferTypes = method_exists($plugin, 'getEnabledTransferTypes') ? (array)$plugin->getEnabledTransferTypes() : []; - $configSchema = (array)$plugin->getConfigSchema(); - - $author = method_exists($plugin, 'getAuthorName') ? (string)$plugin->getAuthorName() : ''; - $link = method_exists($plugin, 'getAuthorLink') ? (string)$plugin->getAuthorLink() : ''; - - $this->pluginRepository->upsertByCode($pluginCode, [ - 'name' => $plugin->getName(), - 'pay_types' => $payTypes, - 'transfer_types' => $transferTypes, - 'config_schema' => $configSchema, - 'author' => $author, - 'link' => $link, - ]); - } -} - diff --git a/app/services/SystemConfigService.php b/app/services/SystemConfigService.php deleted file mode 100644 index 223231b..0000000 --- a/app/services/SystemConfigService.php +++ /dev/null @@ -1,82 +0,0 @@ -configRepository->getValueByKey($configKey); - return $value !== null ? $value : $default; - } - - /** - * 根据配置键名数组批量获取配置值 - * - * @param array $configKeys - * @return array 返回 ['config_key' => 'config_value'] 格式的数组 - */ - public function getValues(array $configKeys): array - { - return $this->configRepository->getValuesByKeys($configKeys); - } - - /** - * 保存配置值 - * - * @param string $configKey - * @param mixed $configValue - * @return bool - */ - public function setValue(string $configKey, $configValue): bool - { - // 如果是数组或对象,转换为JSON字符串 - if (is_array($configValue) || is_object($configValue)) { - $configValue = json_encode($configValue, JSON_UNESCAPED_UNICODE); - } else { - $configValue = (string) $configValue; - } - - return $this->configRepository->updateOrCreate($configKey, $configValue); - } - - /** - * 批量保存配置值 - * - * @param array $configs 格式:['config_key' => 'config_value'] - * @return bool - */ - public function setValues(array $configs): bool - { - // 处理数组和对象类型的值 - $processedConfigs = []; - foreach ($configs as $key => $value) { - if (is_array($value) || is_object($value)) { - $processedConfigs[$key] = json_encode($value, JSON_UNESCAPED_UNICODE); - } else { - $processedConfigs[$key] = (string) $value; - } - } - - return $this->configRepository->batchUpdateOrCreate($processedConfigs); - } -} - diff --git a/app/services/SystemSettingService.php b/app/services/SystemSettingService.php deleted file mode 100644 index 6ddd50a..0000000 --- a/app/services/SystemSettingService.php +++ /dev/null @@ -1,211 +0,0 @@ - $sortB; - }); - - Cache::set(self::CACHE_KEY_TABS, $tabs); - - return $tabs; - } - - /** - * 获取指定 Tab 的表单配置(合并数据库值) - * - * @param string $tabKey - * @return array - */ - public function getFormConfig(string $tabKey): array - { - $cacheKey = self::CACHE_KEY_FORM_PREFIX . $tabKey; - - $formConfig = Cache::get($cacheKey); - if (!is_array($formConfig)) { - $configPath = config_path("base-config/{$tabKey}.json"); - - if (!file_exists($configPath)) { - throw new NotFoundException("表单配置文件不存在:{$tabKey}"); - } - - $jsonContent = file_get_contents($configPath); - $formConfig = json_decode($jsonContent, true); - - if (json_last_error() !== JSON_ERROR_NONE) { - throw new InternalServerException('表单配置文件格式错误:' . json_last_error_msg()); - } - - Cache::set($cacheKey, $formConfig); - } - - // 合并数据库配置值 - if (isset($formConfig['rules']) && is_array($formConfig['rules'])) { - $fieldNames = []; - foreach ($formConfig['rules'] as $rule) { - if (isset($rule['field'])) { - $fieldNames[] = $rule['field']; - } - } - - if (!empty($fieldNames)) { - $dbValues = $this->configService->getValues($fieldNames); - - foreach ($formConfig['rules'] as &$rule) { - if (isset($rule['field']) && isset($dbValues[$rule['field']])) { - $value = $dbValues[$rule['field']]; - - $decoded = json_decode($value, true); - if (json_last_error() === JSON_ERROR_NONE) { - $rule['value'] = $decoded; - } else { - if (isset($rule['type'])) { - switch ($rule['type']) { - case 'inputNumber': - $rule['value'] = is_numeric($value) ? (float) $value : ($rule['value'] ?? 0); - break; - case 'switch': - $rule['value'] = in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); - break; - default: - $rule['value'] = $value; - } - } else { - $rule['value'] = $value; - } - } - } - } - unset($rule); - } - } - - Cache::set($cacheKey, $formConfig); - - return $formConfig; - } - - /** - * 保存表单配置 - * - * @param string $tabKey - * @param array $formData - * @return void - */ - public function saveFormConfig(string $tabKey, array $formData): void - { - $result = $this->configService->setValues($formData); - - if (!$result) { - throw new InternalServerException('保存失败'); - } - - // 清理对应表单缓存 - $cacheKey = self::CACHE_KEY_FORM_PREFIX . $tabKey; - Cache::delete($cacheKey); - } -} - - diff --git a/app/services/api/EpayProtocolService.php b/app/services/api/EpayProtocolService.php deleted file mode 100644 index 522ced8..0000000 --- a/app/services/api/EpayProtocolService.php +++ /dev/null @@ -1,106 +0,0 @@ -method()) { - 'GET' => $request->get(), - 'POST' => $request->post(), - default => $request->all(), - }; - - $params = EpayValidator::make($data) - ->withScene('submit') - ->validate(); - - $result = $this->epayService->submit($params, $request); - $payParams = $result['pay_params'] ?? []; - - if (($payParams['type'] ?? '') === 'redirect' && !empty($payParams['url'])) { - return [ - 'response_type' => 'redirect', - 'url' => $payParams['url'], - ]; - } - - if (($payParams['type'] ?? '') === 'form') { - if (!empty($payParams['html'])) { - return [ - 'response_type' => 'form_html', - 'html' => $payParams['html'], - ]; - } - - return [ - 'response_type' => 'form_params', - 'form' => $payParams, - ]; - } - - return [ - 'response_type' => 'error', - ]; - } - - /** - * 处理 mapi.php 请求 - */ - public function handleMapi(Request $request): array - { - $params = EpayValidator::make($request->post()) - ->withScene('mapi') - ->validate(); - - return $this->epayService->mapi($params, $request); - } - - /** - * 处理 api.php 请求 - */ - public function handleApi(Request $request): array - { - $data = array_merge($request->get(), $request->post()); - $act = strtolower((string)($data['act'] ?? '')); - - if ($act === 'order') { - $params = EpayValidator::make($data) - ->withScene('api_order') - ->validate(); - return $this->epayService->api($params); - } - - if ($act === 'refund') { - $params = EpayValidator::make($data) - ->withScene('api_refund') - ->validate(); - return $this->epayService->api($params); - } - - return [ - 'code' => 0, - 'msg' => '不支持的操作类型', - ]; - } -} - diff --git a/app/services/api/EpayService.php b/app/services/api/EpayService.php deleted file mode 100644 index 3130dfc..0000000 --- a/app/services/api/EpayService.php +++ /dev/null @@ -1,267 +0,0 @@ -createOrder($data, $request); - } - - /** - * API 接口支付(mapi.php) - * - * @param array $data - * @param Request $request - * @return array 符合易支付文档的返回结构 - */ - public function mapi(array $data, Request $request): array - { - $result = $this->createOrder($data, $request); - $payParams = $result['pay_params'] ?? []; - - $response = [ - 'code' => 1, - 'msg' => 'success', - 'trade_no' => $result['order_id'], - ]; - - if (!empty($payParams['type'])) { - switch ($payParams['type']) { - case 'redirect': - $response['payurl'] = $payParams['url'] ?? ''; - break; - case 'qrcode': - $response['qrcode'] = $payParams['qrcode_url'] ?? $payParams['qrcode_data'] ?? ''; - break; - case 'jsapi': - if (!empty($payParams['urlscheme'])) { - $response['urlscheme'] = $payParams['urlscheme']; - } - break; - default: - // 不识别的类型不返回额外字段 - break; - } - } - - return $response; - } - - /** - * API 接口(api.php)- 处理 act=order / refund 等 - * - * @param array $data - * @return array - */ - public function api(array $data): array - { - $act = strtolower($data['act'] ?? ''); - - return match ($act) { - 'order' => $this->apiOrder($data), - 'refund' => $this->apiRefund($data), - default => [ - 'code' => 0, - 'msg' => '不支持的操作类型', - ], - }; - } - - /** - * api.php?act=order 查询单个订单 - */ - private function apiOrder(array $data): array - { - $pid = (int)($data['pid'] ?? 0); - $key = (string)($data['key'] ?? ''); - - if ($pid <= 0 || $key === '') { - throw new BadRequestException('商户参数错误'); - } - - $app = $this->merchantAppRepository->findByAppId((string)$pid); - if (!$app || $app->app_secret !== $key) { - throw new NotFoundException('商户不存在或密钥错误'); - } - - $tradeNo = $data['trade_no'] ?? ''; - $outTradeNo = $data['out_trade_no'] ?? ''; - - if ($tradeNo === '' && $outTradeNo === '') { - throw new BadRequestException('系统订单号与商户订单号不能同时为空'); - } - - if ($tradeNo !== '') { - $order = $this->orderRepository->findByOrderId($tradeNo); - } else { - $order = $this->orderRepository->findByMchNo($app->merchant_id, $app->id, $outTradeNo); - } - - if (!$order) { - throw new NotFoundException('订单不存在'); - } - - $methodCode = $this->getMethodCodeByOrder($order); - - return [ - 'code' => 1, - 'msg' => '查询订单号成功!', - 'trade_no' => $order->order_id, - 'out_trade_no' => $order->mch_order_no, - 'api_trade_no' => $order->chan_trade_no ?? '', - 'type' => $methodCode, - 'pid' => (int)$pid, - 'addtime' => $order->created_at, - 'endtime' => $order->pay_at, - 'name' => $order->subject, - 'money' => (string)$order->amount, - 'status' => $order->status === PaymentOrder::STATUS_SUCCESS ? 1 : 0, - 'param' => $order->extra['param'] ?? '', - 'buyer' => '', - ]; - } - - /** - * api.php?act=refund 提交订单退款 - */ - private function apiRefund(array $data): array - { - $pid = (int)($data['pid'] ?? 0); - $key = (string)($data['key'] ?? ''); - - if ($pid <= 0 || $key === '') { - throw new BadRequestException('商户参数错误'); - } - - $app = $this->merchantAppRepository->findByAppId((string)$pid); - if (!$app || $app->app_secret !== $key) { - throw new NotFoundException('商户不存在或密钥错误'); - } - - $tradeNo = $data['trade_no'] ?? ''; - $outTradeNo = $data['out_trade_no'] ?? ''; - $money = (float)($data['money'] ?? 0); - - if ($tradeNo === '' && $outTradeNo === '') { - throw new BadRequestException('系统订单号与商户订单号不能同时为空'); - } - if ($money <= 0) { - throw new BadRequestException('退款金额必须大于0'); - } - - if ($tradeNo !== '') { - $order = $this->orderRepository->findByOrderId($tradeNo); - } else { - $order = $this->orderRepository->findByMchNo($app->merchant_id, $app->id, $outTradeNo); - } - - if (!$order) { - throw new NotFoundException('订单不存在'); - } - - $refundResult = $this->payOrderService->refundOrder([ - 'order_id' => $order->order_id, - 'refund_amount' => $money, - ]); - - return [ - 'code' => 1, - 'msg' => '退款成功', - ]; - } - - /** - * 创建订单并调用插件统一下单 - * - * @param array $data - * @param Request $request - * @return array - */ - private function createOrder(array $data, Request $request): array - { - $pid = (int)($data['pid'] ?? 0); - if ($pid <= 0) { - throw new BadRequestException('应用ID不能为空'); - } - - // 根据 pid 映射应用(约定 pid = app_id) - $app = $this->merchantAppRepository->findByAppId((string)$pid); - if (!$app || $app->status !== 1) { - throw new NotFoundException('商户应用不存在或已禁用'); - } - - // 易支付签名校验:使用 app_secret 作为 key - $signType = strtolower((string)($data['sign_type'] ?? 'md5')); - if ($signType !== 'md5') { - throw new BadRequestException('不支持的签名类型:' . ($data['sign_type'] ?? '')); - } - if (!EpayUtil::verify($data, (string)$app->app_secret)) { - throw new UnauthorizedException('签名验证失败'); - } - - $orderData = [ - 'mch_id' => $app->merchant_id, - 'app_id' => $app->id, - 'mch_order_no' => $data['out_trade_no'], - 'pay_type' => $data['type'], - 'amount' => sprintf('%.2f', (float)$data['money']), - 'subject' => $data['name'], - 'body' => $data['name'], - 'client_ip' => $data['clientip'] ?? $request->getRemoteIp(), - 'extra' => [ - 'param' => $data['param'] ?? '', - 'notify_url' => $data['notify_url'] ?? '', - 'return_url' => $data['return_url'] ?? '', - ], - ]; - - // 调用通用支付服务完成通道选择与插件下单 - return $this->payService->pay($orderData, [ - 'device' => $data['device'] ?? '', - 'request' => $request, - ]); - } - - /** - * 根据订单获取支付方式编码 - */ - private function getMethodCodeByOrder(PaymentOrder $order): string - { - $method = $this->methodRepository->find($order->method_id); - return $method ? $method->method_code : ''; - } -} diff --git a/app/validation/EpayValidator.php b/app/validation/EpayValidator.php deleted file mode 100644 index 7c504ba..0000000 --- a/app/validation/EpayValidator.php +++ /dev/null @@ -1,122 +0,0 @@ - 'required|integer', - 'key' => 'sometimes|string', - - // 支付相关 - 'type' => 'sometimes|string', - 'out_trade_no' => 'required|string|max:64', - 'trade_no' => 'sometimes|string|max:64', - 'notify_url' => 'required|url|max:255', - 'return_url' => 'sometimes|url|max:255', - 'name' => 'required|string|max:127', - 'money' => 'required|numeric|min:0.01', - 'clientip' => 'sometimes|ip', - 'device' => 'sometimes|string|in:pc,mobile,qq,wechat,alipay,jump', - 'param' => 'sometimes|string|max:255', - - // 签名相关 - 'sign' => 'required|string|size:32', - 'sign_type' => 'required|string|in:MD5,md5', - - // API 动作 - 'act' => 'required|string', - 'limit' => 'sometimes|integer|min:1|max:50', - 'page' => 'sometimes|integer|min:1', - ]; - - protected array $messages = []; - - protected array $attributes = [ - 'pid' => '商户ID', - 'key' => '商户密钥', - 'type' => '支付方式', - 'out_trade_no' => '商户订单号', - 'trade_no' => '系统订单号', - 'notify_url' => '异步通知地址', - 'return_url' => '跳转通知地址', - 'name' => '商品名称', - 'money' => '商品金额', - 'clientip' => '用户IP地址', - 'device' => '设备类型', - 'param' => '业务扩展参数', - 'sign' => '签名字符串', - 'sign_type' => '签名类型', - 'act' => '操作类型', - 'limit' => '查询数量', - 'page' => '页码', - ]; - - /** - * 不同接口场景 - */ - protected array $scenes = [ - // 页面跳转支付 submit.php - 'submit' => [ - 'pid', - 'type', - 'out_trade_no', - 'notify_url', - 'return_url', - 'name', - 'money', - 'param', - 'sign', - 'sign_type', - ], - - // API 接口支付 mapi.php - 'mapi' => [ - 'pid', - 'type', - 'out_trade_no', - 'notify_url', - 'return_url', - 'name', - 'money', - 'clientip', - 'device', - 'param', - 'sign', - 'sign_type', - ], - - // api.php?act=order 查询单个订单 - 'api_order' => [ - 'act', - 'pid', - 'key', - // trade_no 与 out_trade_no 至少一个,由业务层进一步校验 - ], - - // api.php?act=refund 提交退款 - 'api_refund' => [ - 'act', - 'pid', - 'key', - 'money', - // trade_no/out_trade_no 至少一个 - ], - ]; -} - - diff --git a/app/validation/SystemConfigValidator.php b/app/validation/SystemConfigValidator.php deleted file mode 100644 index b73e820..0000000 --- a/app/validation/SystemConfigValidator.php +++ /dev/null @@ -1,23 +0,0 @@ - 'string|max:100', - 'config_value' => 'nullable|string', - ]; - - protected array $messages = []; - - protected array $attributes = [ - 'config_key' => '配置项键名', - 'config_value' => '配置项值', - ]; - - protected array $scenes = []; -} diff --git a/composer.json b/composer.json index faa8a9c..c163cda 100644 --- a/composer.json +++ b/composer.json @@ -27,21 +27,23 @@ "php": ">=8.1", "workerman/webman-framework": "^2.1", "monolog/monolog": "^2.0", - "php-di/php-di": "7.0", - "webman/database": "^2.1", - "webman/redis": "^2.1", - "illuminate/events": "^12.49", - "webman/cache": "^2.1", "webman/console": "^2.2", + "webman/database": "^2.1", + "illuminate/pagination": "^12.55", + "illuminate/events": "^12.55", + "symfony/var-dumper": "^7.4", + "webman/redis": "^2.1", + "webman/validation": "^2.2", + "webman/cache": "^2.1", "webman/captcha": "^1.0", "webman/event": "^1.0", "vlucas/phpdotenv": "^5.6", "workerman/crontab": "^1.0", - "webman/redis-queue": "^2.1", - "firebase/php-jwt": "^7.0", - "webman/validation": "^2.2", - "illuminate/pagination": "^12.53", - "yansongda/pay": "~3.7.0" + "php-di/php-di": "7.0", + "firebase/php-jwt": "7.0", + "yansongda/pay": "~3.0", + "alibabacloud/oss-v2": "^0.4.0", + "qcloud/cos-sdk-v5": "^2.6" }, "suggest": { "ext-event": "For better performance. " @@ -63,6 +65,12 @@ ], "pre-package-uninstall": [ "support\\Plugin::uninstall" + ], + "post-create-project-cmd": [ + "support\\Setup::run" + ], + "setup-webman": [ + "support\\Setup::run" ] }, "minimum-stability": "dev", diff --git a/composer.lock b/composer.lock index cfda262..00701fa 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,70 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ef4cd33c0940c77fc8b5fffd35cd9bb7", + "content-hash": "fca3ef829e0c2987bb87656ddbd0a519", "packages": [ { - "name": "brick/math", - "version": "0.14.7", + "name": "alibabacloud/oss-v2", + "version": "0.4.0", "source": { "type": "git", - "url": "https://github.com/brick/math.git", - "reference": "07ff363b16ef8aca9692bba3be9e73fe63f34e50" + "url": "https://github.com/aliyun/alibabacloud-oss-php-sdk-v2.git", + "reference": "a65ffd843cc8429c24a091804edd3a05f43050e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/07ff363b16ef8aca9692bba3be9e73fe63f34e50", - "reference": "07ff363b16ef8aca9692bba3be9e73fe63f34e50", + "url": "https://api.github.com/repos/aliyun/alibabacloud-oss-php-sdk-v2/zipball/a65ffd843cc8429c24a091804edd3a05f43050e4", + "reference": "a65ffd843cc8429c24a091804edd3a05f43050e4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", + "guzzlehttp/promises": "^1.4.0 || ^2.0", + "guzzlehttp/psr7": "^2.7", + "php": "^7.4 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "AlibabaCloud\\Oss\\V2\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Alibaba Cloud OSS SDK", + "email": "sdk-team@alibabacloud.com" + } + ], + "description": "Aliyun OSS SDK for PHP v2", + "homepage": "https://github.com/aliyun/alibabacloud-oss-php-sdk-v2", + "support": { + "issues": "https://github.com/aliyun/alibabacloud-oss-php-sdk-v2/issues", + "source": "https://github.com/aliyun/alibabacloud-oss-php-sdk-v2/tree/0.4.0" + }, + "time": "2025-12-25T08:21:31+00:00" + }, + { + "name": "brick/math", + "version": "0.14.8", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { @@ -56,7 +106,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.7" + "source": "https://github.com/brick/math/tree/0.14.8" }, "funding": [ { @@ -64,7 +114,7 @@ "type": "github" } ], - "time": "2026-02-07T10:57:35+00:00" + "time": "2026-02-10T14:33:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -371,16 +421,16 @@ }, { "name": "firebase/php-jwt", - "version": "v7.0.2", + "version": "v7.0.0", "source": { "type": "git", "url": "https://github.com/firebase/php-jwt.git", - "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" + "reference": "c03036fd5dbd530a95406ca3b5f6d7b24eaa3910" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", - "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/c03036fd5dbd530a95406ca3b5f6d7b24eaa3910", + "reference": "c03036fd5dbd530a95406ca3b5f6d7b24eaa3910", "shasum": "" }, "require": { @@ -428,9 +478,9 @@ ], "support": { "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v7.0.2" + "source": "https://github.com/firebase/php-jwt/tree/v7.0.0" }, - "time": "2025-12-16T22:17:28+00:00" + "time": "2025-12-15T19:26:43+00:00" }, { "name": "fruitcake/php-cors", @@ -565,6 +615,89 @@ ], "time": "2025-12-27T19:43:20+00:00" }, + { + "name": "guzzlehttp/command", + "version": "1.3.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/command.git", + "reference": "888e74fc1d82a499c1fd6726248ed0bc0886395e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/command/zipball/888e74fc1d82a499c1fd6726248ed0bc0886395e", + "reference": "888e74fc1d82a499c1fd6726248ed0bc0886395e", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9.2", + "guzzlehttp/promises": "^1.5.3 || ^2.0.3", + "guzzlehttp/psr7": "^2.7.0", + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.19 || ^9.5.8" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Command\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "Provides the foundation for building command-based web service clients", + "support": { + "issues": "https://github.com/guzzle/command/issues", + "source": "https://github.com/guzzle/command/tree/1.3.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/command", + "type": "tidelift" + } + ], + "time": "2025-02-04T09:56:46+00:00" + }, { "name": "guzzlehttp/guzzle", "version": "7.10.0", @@ -691,6 +824,93 @@ ], "time": "2025-08-23T22:36:01+00:00" }, + { + "name": "guzzlehttp/guzzle-services", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle-services.git", + "reference": "45bfeb80d5ed072bb39e9f6ed1ec5d650edae961" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle-services/zipball/45bfeb80d5ed072bb39e9f6ed1ec5d650edae961", + "reference": "45bfeb80d5ed072bb39e9f6ed1ec5d650edae961", + "shasum": "" + }, + "require": { + "guzzlehttp/command": "^1.3.2", + "guzzlehttp/guzzle": "^7.9.2", + "guzzlehttp/psr7": "^2.7.0", + "guzzlehttp/uri-template": "^1.0.4", + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.19 || ^9.5.8" + }, + "suggest": { + "gimler/guzzle-description-loader": "^0.0.4" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Command\\Guzzle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Stefano Kowalke", + "email": "blueduck@mail.org", + "homepage": "https://github.com/Konafets" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "Provides an implementation of the Guzzle Command library that uses Guzzle service descriptions to describe web services, serialize requests, and parse responses into easy to use model structures.", + "support": { + "issues": "https://github.com/guzzle/guzzle-services/issues", + "source": "https://github.com/guzzle/guzzle-services/tree/1.4.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle-services", + "type": "tidelift" + } + ], + "time": "2025-02-04T09:59:21+00:00" + }, { "name": "guzzlehttp/promises", "version": "2.3.0", @@ -776,16 +996,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.8.0", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "21dc724a0583619cd1652f673303492272778051" + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", - "reference": "21dc724a0583619cd1652f673303492272778051", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", "shasum": "" }, "require": { @@ -801,6 +1021,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { @@ -872,7 +1093,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.8.0" + "source": "https://github.com/guzzle/psr7/tree/2.9.0" }, "funding": [ { @@ -888,7 +1109,7 @@ "type": "tidelift" } ], - "time": "2025-08-23T21:21:41+00:00" + "time": "2026-03-10T16:41:02+00:00" }, { "name": "guzzlehttp/uri-template", @@ -978,16 +1199,16 @@ }, { "name": "illuminate/bus", - "version": "v12.49.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/bus.git", - "reference": "34a5617fe40c90000751bd72bcc9ab9b012d9b41" + "reference": "9e70f409c744e0ba6b301fa608bb5b3c8c38669a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/bus/zipball/34a5617fe40c90000751bd72bcc9ab9b012d9b41", - "reference": "34a5617fe40c90000751bd72bcc9ab9b012d9b41", + "url": "https://api.github.com/repos/illuminate/bus/zipball/9e70f409c744e0ba6b301fa608bb5b3c8c38669a", + "reference": "9e70f409c744e0ba6b301fa608bb5b3c8c38669a", "shasum": "" }, "require": { @@ -1027,20 +1248,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-20T15:12:10+00:00" + "time": "2026-03-09T13:55:34+00:00" }, { "name": "illuminate/collections", - "version": "v12.50.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/collections.git", - "reference": "b4bbe2a929aaacf0ade3bec535f1f8fac6e6ed38" + "reference": "83313b009c4afb6f02dbc090bdb67809756eefa2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/collections/zipball/b4bbe2a929aaacf0ade3bec535f1f8fac6e6ed38", - "reference": "b4bbe2a929aaacf0ade3bec535f1f8fac6e6ed38", + "url": "https://api.github.com/repos/illuminate/collections/zipball/83313b009c4afb6f02dbc090bdb67809756eefa2", + "reference": "83313b009c4afb6f02dbc090bdb67809756eefa2", "shasum": "" }, "require": { @@ -1087,11 +1308,11 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-01T16:38:26+00:00" + "time": "2026-03-11T14:13:25+00:00" }, { "name": "illuminate/conditionable", - "version": "v12.50.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/conditionable.git", @@ -1137,16 +1358,16 @@ }, { "name": "illuminate/container", - "version": "v12.50.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/container.git", - "reference": "a605f25d0e014b6e521bcb142a4eba578966a24f" + "reference": "9abe9dba6b3f5220205ce143069c975a601e4294" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/container/zipball/a605f25d0e014b6e521bcb142a4eba578966a24f", - "reference": "a605f25d0e014b6e521bcb142a4eba578966a24f", + "url": "https://api.github.com/repos/illuminate/container/zipball/9abe9dba6b3f5220205ce143069c975a601e4294", + "reference": "9abe9dba6b3f5220205ce143069c975a601e4294", "shasum": "" }, "require": { @@ -1195,20 +1416,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-29T03:13:58+00:00" + "time": "2026-03-16T14:05:01+00:00" }, { "name": "illuminate/contracts", - "version": "v12.50.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/contracts.git", - "reference": "3d4eeab332c04a9eaea90968c19a66f78745e47a" + "reference": "099fd9b56ccaf776facaa27699b960a3f2451127" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/contracts/zipball/3d4eeab332c04a9eaea90968c19a66f78745e47a", - "reference": "3d4eeab332c04a9eaea90968c19a66f78745e47a", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/099fd9b56ccaf776facaa27699b960a3f2451127", + "reference": "099fd9b56ccaf776facaa27699b960a3f2451127", "shasum": "" }, "require": { @@ -1243,20 +1464,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-28T15:26:27+00:00" + "time": "2026-02-20T14:37:40+00:00" }, { "name": "illuminate/database", - "version": "v12.50.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/database.git", - "reference": "a20d2278cca2b9052381fbfab301d9bc7b5c47d1" + "reference": "60b2fbfb73cc9bd0cb1c27234ee397b0e2ebbadb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/database/zipball/a20d2278cca2b9052381fbfab301d9bc7b5c47d1", - "reference": "a20d2278cca2b9052381fbfab301d9bc7b5c47d1", + "url": "https://api.github.com/repos/illuminate/database/zipball/60b2fbfb73cc9bd0cb1c27234ee397b0e2ebbadb", + "reference": "60b2fbfb73cc9bd0cb1c27234ee397b0e2ebbadb", "shasum": "" }, "require": { @@ -1315,20 +1536,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-04T15:21:01+00:00" + "time": "2026-03-17T13:42:04+00:00" }, { "name": "illuminate/events", - "version": "v12.49.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/events.git", - "reference": "829cff84e98a840bfcd68299a3ab4611a295d838" + "reference": "ff22aa6017bdbd90ace660f50638ed9c580ba555" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/events/zipball/829cff84e98a840bfcd68299a3ab4611a295d838", - "reference": "829cff84e98a840bfcd68299a3ab4611a295d838", + "url": "https://api.github.com/repos/illuminate/events/zipball/ff22aa6017bdbd90ace660f50638ed9c580ba555", + "reference": "ff22aa6017bdbd90ace660f50638ed9c580ba555", "shasum": "" }, "require": { @@ -1370,20 +1591,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-08T15:49:00+00:00" + "time": "2026-03-05T16:20:14+00:00" }, { "name": "illuminate/filesystem", - "version": "v12.50.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/filesystem.git", - "reference": "8cad07cf6004a7cd0a876c6ad2136a1511c2bb58" + "reference": "b91eede30e1bde98cb51fb4c4f28269a8dea593e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/filesystem/zipball/8cad07cf6004a7cd0a876c6ad2136a1511c2bb58", - "reference": "8cad07cf6004a7cd0a876c6ad2136a1511c2bb58", + "url": "https://api.github.com/repos/illuminate/filesystem/zipball/b91eede30e1bde98cb51fb4c4f28269a8dea593e", + "reference": "b91eede30e1bde98cb51fb4c4f28269a8dea593e", "shasum": "" }, "require": { @@ -1437,20 +1658,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-19T15:15:34+00:00" + "time": "2026-03-09T14:26:54+00:00" }, { "name": "illuminate/http", - "version": "v12.50.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/http.git", - "reference": "03a49ac6a09bdb5c0420758dec50216abbffcb70" + "reference": "9e0cf400cb81333520994952a164c737224ce9f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/http/zipball/03a49ac6a09bdb5c0420758dec50216abbffcb70", - "reference": "03a49ac6a09bdb5c0420758dec50216abbffcb70", + "url": "https://api.github.com/repos/illuminate/http/zipball/9e0cf400cb81333520994952a164c737224ce9f0", + "reference": "9e0cf400cb81333520994952a164c737224ce9f0", "shasum": "" }, "require": { @@ -1499,11 +1720,11 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-04T15:08:54+00:00" + "time": "2026-03-17T18:15:14+00:00" }, { "name": "illuminate/macroable", - "version": "v12.50.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/macroable.git", @@ -1549,16 +1770,16 @@ }, { "name": "illuminate/pagination", - "version": "v12.53.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/pagination.git", - "reference": "87e7e3e7b02d6809b1bcd41782e1ca2c6d2a413b" + "reference": "8327d828676654053906a771abf5eea5426354ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/pagination/zipball/87e7e3e7b02d6809b1bcd41782e1ca2c6d2a413b", - "reference": "87e7e3e7b02d6809b1bcd41782e1ca2c6d2a413b", + "url": "https://api.github.com/repos/illuminate/pagination/zipball/8327d828676654053906a771abf5eea5426354ec", + "reference": "8327d828676654053906a771abf5eea5426354ec", "shasum": "" }, "require": { @@ -1595,11 +1816,11 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-11-16T14:36:17+00:00" + "time": "2026-02-25T15:25:18+00:00" }, { "name": "illuminate/pipeline", - "version": "v12.49.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/pipeline.git", @@ -1651,16 +1872,16 @@ }, { "name": "illuminate/redis", - "version": "v12.49.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/redis.git", - "reference": "26f41f22f285295f8eeb1e00ba3ff3f24021a9eb" + "reference": "cfed351568462ca0310823f0f10fba0dce9354d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/redis/zipball/26f41f22f285295f8eeb1e00ba3ff3f24021a9eb", - "reference": "26f41f22f285295f8eeb1e00ba3ff3f24021a9eb", + "url": "https://api.github.com/repos/illuminate/redis/zipball/cfed351568462ca0310823f0f10fba0dce9354d7", + "reference": "cfed351568462ca0310823f0f10fba0dce9354d7", "shasum": "" }, "require": { @@ -1701,20 +1922,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-02T17:50:54+00:00" + "time": "2026-03-12T14:06:19+00:00" }, { "name": "illuminate/reflection", - "version": "v12.50.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/reflection.git", - "reference": "6188e97a587371b9951c2a7e337cd760308c17d7" + "reference": "348cf5da9de89b596d7723be6425fb048e2bf4bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/reflection/zipball/6188e97a587371b9951c2a7e337cd760308c17d7", - "reference": "6188e97a587371b9951c2a7e337cd760308c17d7", + "url": "https://api.github.com/repos/illuminate/reflection/zipball/348cf5da9de89b596d7723be6425fb048e2bf4bb", + "reference": "348cf5da9de89b596d7723be6425fb048e2bf4bb", "shasum": "" }, "require": { @@ -1752,20 +1973,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-04T15:21:22+00:00" + "time": "2026-02-25T15:25:18+00:00" }, { "name": "illuminate/session", - "version": "v12.50.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/session.git", - "reference": "ddd0fce2f49889eede824db81c283b4e82aa77d0" + "reference": "98802e67dd5e059c0b978b3fe8f5f0a3ac17ec4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/session/zipball/ddd0fce2f49889eede824db81c283b4e82aa77d0", - "reference": "ddd0fce2f49889eede824db81c283b4e82aa77d0", + "url": "https://api.github.com/repos/illuminate/session/zipball/98802e67dd5e059c0b978b3fe8f5f0a3ac17ec4e", + "reference": "98802e67dd5e059c0b978b3fe8f5f0a3ac17ec4e", "shasum": "" }, "require": { @@ -1809,20 +2030,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-22T17:27:05+00:00" + "time": "2026-02-14T23:03:41+00:00" }, { "name": "illuminate/support", - "version": "v12.50.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/support.git", - "reference": "411a11401406e7d542aa67a4b400feed6bedef0c" + "reference": "cd8a3c5a95501b9ae0828ac785b5af5ffccdca45" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/support/zipball/411a11401406e7d542aa67a4b400feed6bedef0c", - "reference": "411a11401406e7d542aa67a4b400feed6bedef0c", + "url": "https://api.github.com/repos/illuminate/support/zipball/cd8a3c5a95501b9ae0828ac785b5af5ffccdca45", + "reference": "cd8a3c5a95501b9ae0828ac785b5af5ffccdca45", "shasum": "" }, "require": { @@ -1889,20 +2110,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-04T15:14:59+00:00" + "time": "2026-03-17T13:55:49+00:00" }, { "name": "illuminate/translation", - "version": "v12.52.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/translation.git", - "reference": "18aa24aba6f2ab2447b9b903ae7360725fe5bdd0" + "reference": "b341d8fb9dca956dead8726b247761561e7a1530" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/translation/zipball/18aa24aba6f2ab2447b9b903ae7360725fe5bdd0", - "reference": "18aa24aba6f2ab2447b9b903ae7360725fe5bdd0", + "url": "https://api.github.com/repos/illuminate/translation/zipball/b341d8fb9dca956dead8726b247761561e7a1530", + "reference": "b341d8fb9dca956dead8726b247761561e7a1530", "shasum": "" }, "require": { @@ -1940,20 +2161,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-06T12:12:31+00:00" + "time": "2026-03-13T13:36:16+00:00" }, { "name": "illuminate/validation", - "version": "v12.52.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/illuminate/validation.git", - "reference": "195b7dd66548a6a82dd93bf6280926bf9039903c" + "reference": "a520aea3bc774e9709b2286c63c4212af7cc8309" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/validation/zipball/195b7dd66548a6a82dd93bf6280926bf9039903c", - "reference": "195b7dd66548a6a82dd93bf6280926bf9039903c", + "url": "https://api.github.com/repos/illuminate/validation/zipball/a520aea3bc774e9709b2286c63c4212af7cc8309", + "reference": "a520aea3bc774e9709b2286c63c4212af7cc8309", "shasum": "" }, "require": { @@ -2003,7 +2224,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-14T23:03:41+00:00" + "time": "2026-03-16T14:11:09+00:00" }, { "name": "laravel/serializable-closure", @@ -2170,16 +2391,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.1", + "version": "3.11.3", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "f438fcc98f92babee98381d399c65336f3a3827f" + "reference": "6a7e652845bb018c668220c2a545aded8594fbbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f", - "reference": "f438fcc98f92babee98381d399c65336f3a3827f", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6a7e652845bb018c668220c2a545aded8594fbbf", + "reference": "6a7e652845bb018c668220c2a545aded8594fbbf", "shasum": "" }, "require": { @@ -2271,7 +2492,7 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:26:29+00:00" + "time": "2026-03-11T17:23:39+00:00" }, { "name": "nikic/fast-route", @@ -2987,6 +3208,75 @@ }, "time": "2021-10-29T13:26:27+00:00" }, + { + "name": "qcloud/cos-sdk-v5", + "version": "v2.6.16", + "source": { + "type": "git", + "url": "https://github.com/tencentyun/cos-php-sdk-v5.git", + "reference": "22366f4b4f7f277e67aa72eea8d1e02a5f9943e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tencentyun/cos-php-sdk-v5/zipball/22366f4b4f7f277e67aa72eea8d1e02a5f9943e2", + "reference": "22366f4b4f7f277e67aa72eea8d1e02a5f9943e2", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^6.2.1 || ^7.0", + "guzzlehttp/guzzle-services": "^1.1", + "guzzlehttp/psr7": "^1.3.1 || ^2.0", + "php": ">=5.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.4-dev" + } + }, + "autoload": { + "files": [ + "src/Common.php" + ], + "psr-4": { + "Qcloud\\Cos\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "yaozongyou", + "email": "yaozongyou@vip.qq.com" + }, + { + "name": "lewzylu", + "email": "327874225@qq.com" + }, + { + "name": "tuunalai", + "email": "550566181@qq.com" + } + ], + "description": "PHP SDK for QCloud COS", + "keywords": [ + "cos", + "php", + "qcloud" + ], + "support": { + "issues": "https://github.com/tencentyun/cos-php-sdk-v5/issues", + "source": "https://github.com/tencentyun/cos-php-sdk-v5/tree/v2.6.16" + }, + "time": "2025-01-21T12:49:21+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", @@ -3289,16 +3579,16 @@ }, { "name": "symfony/console", - "version": "v7.4.4", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", "shasum": "" }, "require": { @@ -3363,7 +3653,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.4" + "source": "https://github.com/symfony/console/tree/v7.4.7" }, "funding": [ { @@ -3383,7 +3673,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T11:36:38+00:00" + "time": "2026-03-06T14:06:20+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3697,16 +3987,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", "shasum": "" }, "require": { @@ -3741,7 +4031,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.5" + "source": "https://github.com/symfony/finder/tree/v7.4.6" }, "funding": [ { @@ -3761,20 +4051,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-01-29T09:40:50+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.5", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6" + "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/446d0db2b1f21575f1284b74533e425096abdfb6", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81", + "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81", "shasum": "" }, "require": { @@ -3823,7 +4113,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.5" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.7" }, "funding": [ { @@ -3843,20 +4133,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:16:02+00:00" + "time": "2026-03-06T13:15:18+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.5", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a" + "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/229eda477017f92bd2ce7615d06222ec0c19e82a", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1", + "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1", "shasum": "" }, "require": { @@ -3898,7 +4188,7 @@ "symfony/config": "^6.4|^7.0|^8.0", "symfony/console": "^6.4|^7.0|^8.0", "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", "symfony/dom-crawler": "^6.4|^7.0|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0", "symfony/finder": "^6.4|^7.0|^8.0", @@ -3942,7 +4232,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.5" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.7" }, "funding": [ { @@ -3962,20 +4252,20 @@ "type": "tidelift" } ], - "time": "2026-01-28T10:33:42+00:00" + "time": "2026-03-06T16:33:18+00:00" }, { "name": "symfony/mime", - "version": "v7.4.5", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148" + "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b18c7e6e9eee1e19958138df10412f3c4c316148", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148", + "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1", + "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1", "shasum": "" }, "require": { @@ -3986,7 +4276,7 @@ }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" @@ -3994,7 +4284,7 @@ "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", @@ -4031,7 +4321,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.5" + "source": "https://github.com/symfony/mime/tree/v7.4.7" }, "funding": [ { @@ -4051,7 +4341,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T08:59:58+00:00" + "time": "2026-03-05T15:24:09+00:00" }, { "name": "symfony/polyfill-ctype", @@ -4888,16 +5178,16 @@ }, { "name": "symfony/string", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f" + "reference": "9f209231affa85aa930a5e46e6eb03381424b30b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/1c4b10461bf2ec27537b5f36105337262f5f5d6f", - "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f", + "url": "https://api.github.com/repos/symfony/string/zipball/9f209231affa85aa930a5e46e6eb03381424b30b", + "reference": "9f209231affa85aa930a5e46e6eb03381424b30b", "shasum": "" }, "require": { @@ -4955,7 +5245,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.4" + "source": "https://github.com/symfony/string/tree/v7.4.6" }, "funding": [ { @@ -4975,20 +5265,20 @@ "type": "tidelift" } ], - "time": "2026-01-12T10:54:30+00:00" + "time": "2026-02-09T09:33:46+00:00" }, { "name": "symfony/translation", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "bfde13711f53f549e73b06d27b35a55207528877" + "reference": "1888cf064399868af3784b9e043240f1d89d25ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/bfde13711f53f549e73b06d27b35a55207528877", - "reference": "bfde13711f53f549e73b06d27b35a55207528877", + "url": "https://api.github.com/repos/symfony/translation/zipball/1888cf064399868af3784b9e043240f1d89d25ce", + "reference": "1888cf064399868af3784b9e043240f1d89d25ce", "shasum": "" }, "require": { @@ -5055,7 +5345,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.4.4" + "source": "https://github.com/symfony/translation/tree/v7.4.6" }, "funding": [ { @@ -5075,7 +5365,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T10:40:19+00:00" + "time": "2026-02-17T07:53:42+00:00" }, { "name": "symfony/translation-contracts", @@ -5161,16 +5451,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282" + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", "shasum": "" }, "require": { @@ -5224,7 +5514,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.4" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" }, "funding": [ { @@ -5244,7 +5534,7 @@ "type": "tidelift" } ], - "time": "2026-01-01T22:13:48+00:00" + "time": "2026-02-15T10:53:20+00:00" }, { "name": "symfony/var-exporter", @@ -5582,16 +5872,16 @@ }, { "name": "webman/console", - "version": "v2.2.1", + "version": "v2.2.2", "source": { "type": "git", "url": "https://github.com/webman-php/console.git", - "reference": "3c1a50296f7b3b3eff3a8fcda8906276dae3a18f" + "reference": "e66e21c3db1685ac76841df3316d488b25e23d81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webman-php/console/zipball/3c1a50296f7b3b3eff3a8fcda8906276dae3a18f", - "reference": "3c1a50296f7b3b3eff3a8fcda8906276dae3a18f", + "url": "https://api.github.com/repos/webman-php/console/zipball/e66e21c3db1685ac76841df3316d488b25e23d81", + "reference": "e66e21c3db1685ac76841df3316d488b25e23d81", "shasum": "" }, "require": { @@ -5632,25 +5922,25 @@ "source": "https://github.com/webman-php/console", "wiki": "http://www.workerman.net/doc/webman" }, - "time": "2026-02-23T02:46:47+00:00" + "time": "2026-02-26T02:45:43+00:00" }, { "name": "webman/database", - "version": "v2.1.9", + "version": "v2.1.10", "source": { "type": "git", "url": "https://github.com/webman-php/database.git", - "reference": "5a1463c96c79b35225f1cbd2f3e65830a7b4da0e" + "reference": "90abfaae7e9fde5f9a4265ae47396bc5c9ecc61c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webman-php/database/zipball/5a1463c96c79b35225f1cbd2f3e65830a7b4da0e", - "reference": "5a1463c96c79b35225f1cbd2f3e65830a7b4da0e", + "url": "https://api.github.com/repos/webman-php/database/zipball/90abfaae7e9fde5f9a4265ae47396bc5c9ecc61c", + "reference": "90abfaae7e9fde5f9a4265ae47396bc5c9ecc61c", "shasum": "" }, "require": { - "illuminate/database": "^10.0 || ^11.0 || ^12.0", - "illuminate/http": "^10.0 || ^11.0 || ^12.0", + "illuminate/database": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/http": "^10.0 || ^11.0 || ^12.0 || ^13.0", "laravel/serializable-closure": "^1.0 || ^2.0", "workerman/webman-framework": "^2.1 || dev-master" }, @@ -5668,9 +5958,9 @@ "description": "Webman database", "support": { "issues": "https://github.com/webman-php/database/issues", - "source": "https://github.com/webman-php/database/tree/v2.1.9" + "source": "https://github.com/webman-php/database/tree/v2.1.10" }, - "time": "2026-02-02T10:45:05+00:00" + "time": "2026-03-19T07:31:04+00:00" }, { "name": "webman/event", @@ -5705,20 +5995,20 @@ }, { "name": "webman/redis", - "version": "v2.1.3", + "version": "v2.1.4", "source": { "type": "git", "url": "https://github.com/webman-php/redis.git", - "reference": "559eb1692d39c6fef5cf526223fff728be6c0fb9" + "reference": "5ef7034665d35ffbcb99e1f285ee617c180a6a92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webman-php/redis/zipball/559eb1692d39c6fef5cf526223fff728be6c0fb9", - "reference": "559eb1692d39c6fef5cf526223fff728be6c0fb9", + "url": "https://api.github.com/repos/webman-php/redis/zipball/5ef7034665d35ffbcb99e1f285ee617c180a6a92", + "reference": "5ef7034665d35ffbcb99e1f285ee617c180a6a92", "shasum": "" }, "require": { - "illuminate/redis": "^10.0 || ^11.0 || ^12.0", + "illuminate/redis": "^10.0 || ^11.0 || ^12.0 || ^13.0", "workerman/webman-framework": "^2.1 || dev-master" }, "type": "library", @@ -5735,61 +6025,28 @@ "description": "Webman redis", "support": { "issues": "https://github.com/webman-php/redis/issues", - "source": "https://github.com/webman-php/redis/tree/v2.1.3" + "source": "https://github.com/webman-php/redis/tree/v2.1.4" }, - "time": "2025-03-14T03:52:14+00:00" - }, - { - "name": "webman/redis-queue", - "version": "v2.1.1", - "source": { - "type": "git", - "url": "https://github.com/webman-php/redis-queue.git", - "reference": "ff4791e21f3c324a47e21da7b6f2dae5a7311dcb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webman-php/redis-queue/zipball/ff4791e21f3c324a47e21da7b6f2dae5a7311dcb", - "reference": "ff4791e21f3c324a47e21da7b6f2dae5a7311dcb", - "shasum": "" - }, - "require": { - "ext-redis": "*", - "php": ">=8.1", - "workerman/redis-queue": "^1.2", - "workerman/webman-framework": "^2.1 || dev-master" - }, - "type": "library", - "autoload": { - "psr-4": { - "Webman\\RedisQueue\\": "./src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "description": "Redis message queue plugin for webman.", - "support": { - "issues": "https://github.com/webman-php/redis-queue/issues", - "source": "https://github.com/webman-php/redis-queue/tree/v2.1.1" - }, - "time": "2025-11-14T07:12:52+00:00" + "time": "2026-03-19T07:35:54+00:00" }, { "name": "webman/validation", - "version": "v2.2.0", + "version": "v2.2.1", "source": { "type": "git", "url": "https://github.com/webman-php/validation.git", - "reference": "afe9c66bf612d969d5657c83617bdb8d92e62ee3" + "reference": "d5117162aed293955fc55ee02a01d9300b226aaa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webman-php/validation/zipball/afe9c66bf612d969d5657c83617bdb8d92e62ee3", - "reference": "afe9c66bf612d969d5657c83617bdb8d92e62ee3", + "url": "https://api.github.com/repos/webman-php/validation/zipball/d5117162aed293955fc55ee02a01d9300b226aaa", + "reference": "d5117162aed293955fc55ee02a01d9300b226aaa", "shasum": "" }, "require": { "illuminate/translation": "*", "illuminate/validation": "*", + "webman/console": ">=2.1.7", "workerman/webman-framework": "^2.1 || dev-master" }, "require-dev": { @@ -5809,26 +6066,27 @@ "description": "Webman plugin webman/validation", "support": { "issues": "https://github.com/webman-php/validation/issues", - "source": "https://github.com/webman-php/validation/tree/v2.2.0" + "source": "https://github.com/webman-php/validation/tree/v2.2.1" }, - "time": "2026-02-21T08:22:43+00:00" + "time": "2026-02-26T03:10:51+00:00" }, { "name": "workerman/coroutine", - "version": "v1.1.4", + "version": "v1.1.5", "source": { "type": "git", "url": "https://github.com/workerman-php/coroutine.git", - "reference": "b0bebfa9d41b992ad0a835ddf2ee8fa5d58eca44" + "reference": "b60e44267b90d398dbfa7a320f3e97b46357ac9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/workerman-php/coroutine/zipball/b0bebfa9d41b992ad0a835ddf2ee8fa5d58eca44", - "reference": "b0bebfa9d41b992ad0a835ddf2ee8fa5d58eca44", + "url": "https://api.github.com/repos/workerman-php/coroutine/zipball/b60e44267b90d398dbfa7a320f3e97b46357ac9f", + "reference": "b60e44267b90d398dbfa7a320f3e97b46357ac9f", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.1", + "workerman/workerman": "^5.1" }, "require-dev": { "phpunit/phpunit": "^11.0", @@ -5848,9 +6106,9 @@ "description": "Workerman coroutine", "support": { "issues": "https://github.com/workerman-php/coroutine/issues", - "source": "https://github.com/workerman-php/coroutine/tree/v1.1.4" + "source": "https://github.com/workerman-php/coroutine/tree/v1.1.5" }, - "time": "2025-10-11T15:09:08+00:00" + "time": "2026-03-12T02:07:37+00:00" }, { "name": "workerman/crontab", @@ -5902,90 +6160,18 @@ }, "time": "2025-01-15T07:20:50+00:00" }, - { - "name": "workerman/redis", - "version": "v2.0.5", - "source": { - "type": "git", - "url": "https://github.com/walkor/redis.git", - "reference": "49627c1809eff1ef7175eb8ee7549234a1d67ec5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/walkor/redis/zipball/49627c1809eff1ef7175eb8ee7549234a1d67ec5", - "reference": "49627c1809eff1ef7175eb8ee7549234a1d67ec5", - "shasum": "" - }, - "require": { - "php": ">=7", - "workerman/workerman": "^4.1.0||^5.0.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Workerman\\Redis\\": "./src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "homepage": "http://www.workerman.net", - "support": { - "issues": "https://github.com/walkor/redis/issues", - "source": "https://github.com/walkor/redis/tree/v2.0.5" - }, - "time": "2025-04-07T01:58:58+00:00" - }, - { - "name": "workerman/redis-queue", - "version": "v1.2.2", - "source": { - "type": "git", - "url": "https://github.com/walkor/redis-queue.git", - "reference": "f0ba4ea9143ae02f39b998ed908d107354cb43c0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/walkor/redis-queue/zipball/f0ba4ea9143ae02f39b998ed908d107354cb43c0", - "reference": "f0ba4ea9143ae02f39b998ed908d107354cb43c0", - "shasum": "" - }, - "require": { - "php": ">=7.0", - "workerman/redis": "^1.0||^2.0", - "workerman/workerman": ">=4.0.20" - }, - "type": "library", - "autoload": { - "psr-4": { - "Workerman\\RedisQueue\\": "./src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Message queue system written in PHP based on workerman and backed by Redis.", - "homepage": "http://www.workerman.net", - "support": { - "issues": "https://github.com/walkor/redis-queue/issues", - "source": "https://github.com/walkor/redis-queue/tree/v1.2.2" - }, - "time": "2026-01-20T14:57:09+00:00" - }, { "name": "workerman/webman-framework", - "version": "v2.1.4", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/walkor/webman-framework.git", - "reference": "f19d1f9e47cc50210555f0b63db5ae1dd584f793" + "reference": "2da4e49259d41925f1732f95f6fb052a3f42ceee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/walkor/webman-framework/zipball/f19d1f9e47cc50210555f0b63db5ae1dd584f793", - "reference": "f19d1f9e47cc50210555f0b63db5ae1dd584f793", + "url": "https://api.github.com/repos/walkor/webman-framework/zipball/2da4e49259d41925f1732f95f6fb052a3f42ceee", + "reference": "2da4e49259d41925f1732f95f6fb052a3f42ceee", "shasum": "" }, "require": { @@ -6038,7 +6224,7 @@ "source": "https://github.com/walkor/webman-framework", "wiki": "https://doc.workerman.net/" }, - "time": "2025-11-10T06:59:23+00:00" + "time": "2026-02-20T02:37:25+00:00" }, { "name": "workerman/workerman", @@ -6192,16 +6378,16 @@ }, { "name": "yansongda/pay", - "version": "v3.7.19", + "version": "v3.7.20", "source": { "type": "git", "url": "https://github.com/yansongda/pay.git", - "reference": "57eaeff84bd4a19c4d09656a3c45250c9a032aa2" + "reference": "26987ebf789f1e7f0a85febb640986ab4289fd7f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/yansongda/pay/zipball/57eaeff84bd4a19c4d09656a3c45250c9a032aa2", - "reference": "57eaeff84bd4a19c4d09656a3c45250c9a032aa2", + "url": "https://api.github.com/repos/yansongda/pay/zipball/26987ebf789f1e7f0a85febb640986ab4289fd7f", + "reference": "26987ebf789f1e7f0a85febb640986ab4289fd7f", "shasum": "" }, "require": { @@ -6261,7 +6447,7 @@ "issues": "https://github.com/yansongda/pay/issues", "source": "https://github.com/yansongda/pay" }, - "time": "2025-12-22T03:30:53+00:00" + "time": "2026-03-23T07:33:29+00:00" }, { "name": "yansongda/supports", diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..f7c76a4 --- /dev/null +++ b/config/auth.php @@ -0,0 +1,28 @@ + env('AUTH_JWT_ISSUER', 'mpay'), + 'leeway' => (int) env('AUTH_JWT_LEEWAY', 30), + 'guards' => [ + 'admin' => [ + 'secret' => env('AUTH_ADMIN_JWT_SECRET', env('AUTH_JWT_SECRET', 'change-me-jwt-secret-use-at-least-32-chars')), + 'ttl' => (int) env('AUTH_ADMIN_JWT_TTL', 86400), + 'redis_prefix' => env('AUTH_ADMIN_JWT_REDIS_PREFIX', 'mpay:auth:admin:'), + ], + 'merchant' => [ + 'secret' => env('AUTH_MERCHANT_JWT_SECRET', env('AUTH_JWT_SECRET', 'change-me-jwt-secret-use-at-least-32-chars')), + 'ttl' => (int) env('AUTH_MERCHANT_JWT_TTL', 86400), + 'redis_prefix' => env('AUTH_MERCHANT_JWT_REDIS_PREFIX', 'mpay:auth:merchant:'), + ], + ], +]; diff --git a/config/autoload.php b/config/autoload.php index ea2ec01..5c76d03 100644 --- a/config/autoload.php +++ b/config/autoload.php @@ -14,7 +14,7 @@ return [ 'files' => [ - base_path() . '/support/helpers.php', + base_path() . '/support/functions.php', base_path() . '/support/Request.php', base_path() . '/support/Response.php', ] diff --git a/config/base-config/basic.json b/config/base-config/basic.json deleted file mode 100644 index 5adb517..0000000 --- a/config/base-config/basic.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "formId": "basic-config", - "title": "基础配置", - "submitText": "保存配置", - "submitUrl": "/adminapi/system/base-config/submit/basic", - "cacheKey": "basic_config_cache", - "refreshAfterSubmit": true, - "rules": [ - { - "type": "input", - "field": "site_name", - "title": "站点名称", - "value": "", - "props": { - "placeholder": "请输入站点名称" - }, - "validate": [ - { - "required": true, - "message": "站点名称不能为空" - } - ] - }, - { - "type": "textarea", - "field": "site_description", - "title": "站点描述", - "value": "", - "props": { - "placeholder": "请输入站点描述", - "autoSize": { - "minRows": 3, - "maxRows": 6 - } - } - }, - { - "type": "input", - "field": "site_logo", - "title": "站点 Logo", - "value": "", - "props": { - "placeholder": "请输入 Logo 地址" - } - }, - { - "type": "input", - "field": "icp_number", - "title": "备案号", - "value": "", - "props": { - "placeholder": "请输入 ICP 备案号" - } - }, - { - "type": "switch", - "field": "site_status", - "title": "站点状态", - "value": true, - "props": { - "checkedText": "开启", - "uncheckedText": "关闭" - } - }, - { - "type": "inputNumber", - "field": "page_size", - "title": "每页显示数量", - "value": 10, - "props": { - "min": 1, - "max": 100, - "precision": 0 - }, - "validate": [ - { - "required": true, - "message": "每页显示数量不能为空" - } - ] - } - ] -} diff --git a/config/base-config/email.json b/config/base-config/email.json deleted file mode 100644 index ccf3059..0000000 --- a/config/base-config/email.json +++ /dev/null @@ -1,111 +0,0 @@ -{ - "formId": "email-config", - "title": "邮件配置", - "submitText": "保存配置", - "submitUrl": "/adminapi/system/base-config/submit/email", - "cacheKey": "email_config_cache", - "refreshAfterSubmit": true, - "rules": [ - { - "type": "input", - "field": "smtp_host", - "title": "SMTP 主机", - "value": "", - "props": { - "placeholder": "例如:smtp.qq.com" - }, - "validate": [ - { - "required": true, - "message": "SMTP 主机不能为空" - } - ] - }, - { - "type": "inputNumber", - "field": "smtp_port", - "title": "SMTP 端口", - "value": 465, - "props": { - "min": 1, - "max": 65535, - "precision": 0 - }, - "validate": [ - { - "required": true, - "message": "SMTP 端口不能为空" - } - ] - }, - { - "type": "switch", - "field": "smtp_ssl", - "title": "启用 SSL", - "value": true, - "props": { - "checkedText": "是", - "uncheckedText": "否" - } - }, - { - "type": "input", - "field": "smtp_username", - "title": "SMTP 用户名", - "value": "", - "props": { - "placeholder": "请输入 SMTP 用户名" - }, - "validate": [ - { - "required": true, - "message": "SMTP 用户名不能为空" - } - ] - }, - { - "type": "input", - "field": "smtp_password", - "title": "SMTP 密码", - "value": "", - "props": { - "type": "password", - "placeholder": "请输入 SMTP 密码或授权码" - }, - "validate": [ - { - "required": true, - "message": "SMTP 密码不能为空" - } - ] - }, - { - "type": "input", - "field": "from_email", - "title": "发件邮箱", - "value": "", - "props": { - "placeholder": "请输入发件邮箱地址" - }, - "validate": [ - { - "required": true, - "message": "发件邮箱不能为空" - }, - { - "type": "email", - "message": "请输入正确的邮箱地址" - } - ] - }, - { - "type": "input", - "field": "from_name", - "title": "发件名称", - "value": "", - "props": { - "placeholder": "请输入发件人名称" - } - } - ] -} diff --git a/config/base-config/permission.json b/config/base-config/permission.json deleted file mode 100644 index ccfca13..0000000 --- a/config/base-config/permission.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "formId": "permission-config", - "title": "权限配置", - "submitText": "保存配置", - "submitUrl": "/adminapi/system/base-config/submit/permission", - "cacheKey": "permission_config_cache", - "refreshAfterSubmit": true, - "rules": [ - { - "type": "switch", - "field": "enable_permission", - "title": "启用权限控制", - "value": true, - "props": { - "checkedText": "启用", - "uncheckedText": "禁用" - } - }, - { - "type": "inputNumber", - "field": "session_timeout", - "title": "会话超时时间(分钟)", - "value": 30, - "props": { - "min": 1, - "max": 1440, - "precision": 0 - }, - "validate": [ - { - "required": true, - "message": "会话超时时间不能为空" - } - ] - }, - { - "type": "inputNumber", - "field": "password_min_length", - "title": "密码最小长度", - "value": 6, - "props": { - "min": 4, - "max": 32, - "precision": 0 - }, - "validate": [ - { - "required": true, - "message": "密码最小长度不能为空" - } - ] - }, - { - "type": "switch", - "field": "require_strong_password", - "title": "要求强密码", - "value": false, - "props": { - "checkedText": "是", - "uncheckedText": "否" - } - }, - { - "type": "inputNumber", - "field": "max_login_attempts", - "title": "最大登录尝试次数", - "value": 5, - "props": { - "min": 1, - "max": 10, - "precision": 0 - }, - "validate": [ - { - "required": true, - "message": "最大登录尝试次数不能为空" - } - ] - }, - { - "type": "inputNumber", - "field": "lockout_duration", - "title": "账户锁定时长(分钟)", - "value": 30, - "props": { - "min": 1, - "max": 1440, - "precision": 0 - }, - "validate": [ - { - "required": true, - "message": "账户锁定时长不能为空" - } - ] - } - ] -} diff --git a/config/base-config/tabs.json b/config/base-config/tabs.json deleted file mode 100644 index 89f464a..0000000 --- a/config/base-config/tabs.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "key": "basic", - "title": "基础配置", - "icon": "settings", - "description": "配置系统基础信息,包括站点名称、Logo、备案号和分页默认值。", - "sort": 1, - "disabled": false - }, - { - "key": "email", - "title": "邮件配置", - "icon": "email", - "description": "配置 SMTP 主机、端口、账号和发件人信息,用于通知发送与联通检查。", - "sort": 2, - "disabled": false - }, - { - "key": "permission", - "title": "权限配置", - "icon": "lock", - "description": "配置后台权限控制、会话超时、密码强度和登录限制等安全参数。", - "sort": 3, - "disabled": false - } -] diff --git a/config/bootstrap.php b/config/bootstrap.php index ec4603f..95d2e87 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -13,5 +13,5 @@ */ return [ - // support\bootstrap\Session::class, + support\bootstrap\Session::class, ]; diff --git a/config/cache.php b/config/cache.php index c17d3c6..ce274c7 100644 --- a/config/cache.php +++ b/config/cache.php @@ -22,10 +22,10 @@ return [ ], 'redis' => [ 'driver' => 'redis', - 'connection' => env('CACHE_REDIS_CONNECTION', 'default') + 'connection' => 'default' ], 'array' => [ 'driver' => 'array' ] ] -]; +]; \ No newline at end of file diff --git a/config/container.php b/config/container.php index 7e637a6..591a956 100644 --- a/config/container.php +++ b/config/container.php @@ -12,8 +12,8 @@ * @license http://www.opensource.org/licenses/mit-license.php MIT License */ - $builder = new \DI\ContainerBuilder(); - $builder->addDefinitions(config('dependence', [])); - $builder->useAutowiring(true); - $builder->useAttributes(true); - return $builder->build(); \ No newline at end of file +$builder = new \DI\ContainerBuilder(); +$builder->addDefinitions(config('dependence', [])); +$builder->useAutowiring(true); +$builder->useAttributes(true); +return $builder->build(); \ No newline at end of file diff --git a/config/database.php b/config/database.php index 1540ad7..49ea617 100644 --- a/config/database.php +++ b/config/database.php @@ -5,10 +5,10 @@ return [ 'mysql' => [ 'driver' => 'mysql', 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', 3306), - 'database' => env('DB_DATABASE', ''), - 'username' => env('DB_USERNAME', ''), - 'password' => env('DB_PASSWORD', ''), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'mpay'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', '123456'), 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_general_ci', 'prefix' => '', @@ -26,4 +26,4 @@ return [ ], ], ], -]; +]; \ No newline at end of file diff --git a/config/dict.php b/config/dict.php new file mode 100644 index 0000000..79354f8 --- /dev/null +++ b/config/dict.php @@ -0,0 +1,201 @@ + [ + 'name' => '性别', + 'code' => 'gender', + 'description' => '这是一个性别字典', + 'list' => [ + ['name' => '女', 'value' => 0], + ['name' => '男', 'value' => 1], + ['name' => '其它', 'value' => 2], + ], + ], + 'status' => [ + 'name' => '通用状态', + 'code' => 'status', + 'description' => '通用启用禁用状态字典', + 'list' => [ + ['name' => '禁用', 'value' => 0], + ['name' => '启用', 'value' => 1], + ], + ], + 'task_status' => [ + 'name' => '任务状态', + 'code' => 'task_status', + 'description' => '任务执行状态字典', + 'list' => [ + ['name' => '待通知', 'value' => 0], + ['name' => '成功', 'value' => 1], + ['name' => '失败', 'value' => 2], + ], + ], + 'merchant_type' => [ + 'name' => '商户类型', + 'code' => 'merchant_type', + 'description' => '商户主体类型字典', + 'list' => [ + ['name' => '个人', 'value' => 0], + ['name' => '企业', 'value' => 1], + ['name' => '其它', 'value' => 2], + ], + ], + 'risk_level' => [ + 'name' => '风控等级', + 'code' => 'risk_level', + 'description' => '商户风控等级字典', + 'list' => [ + ['name' => '低', 'value' => 0], + ['name' => '中', 'value' => 1], + ['name' => '高', 'value' => 2], + ], + ], + 'channel_mode' => [ + 'name' => '通道模式', + 'code' => 'channel_mode', + 'description' => '支付通道模式字典', + 'list' => [ + ['name' => '代收', 'value' => 0], + ['name' => '自收', 'value' => 1], + ], + ], + 'route_mode' => [ + 'name' => '路由模式', + 'code' => 'route_mode', + 'description' => '支付路由模式字典', + 'list' => [ + ['name' => '顺序依次轮询', 'value' => 0], + ['name' => '权重随机轮询', 'value' => 1], + ['name' => '默认启用通道', 'value' => 2], + ], + ], + 'settlement_cycle_type' => [ + 'name' => '结算周期', + 'code' => 'settlement_cycle_type', + 'description' => '结算周期字典', + 'list' => [ + ['name' => 'D0', 'value' => 0], + ['name' => 'D1', 'value' => 1], + ['name' => 'D7', 'value' => 2], + ['name' => 'T1', 'value' => 3], + ['name' => 'OTHER', 'value' => 4], + ], + ], + 'pay_order_status' => [ + 'name' => '支付订单状态', + 'code' => 'pay_order_status', + 'description' => '支付订单状态字典', + 'list' => [ + ['name' => '待创建', 'value' => 0], + ['name' => '支付中', 'value' => 1], + ['name' => '成功', 'value' => 2], + ['name' => '失败', 'value' => 3], + ['name' => '关闭', 'value' => 4], + ['name' => '超时', 'value' => 5], + ], + ], + 'refund_order_status' => [ + 'name' => '退款订单状态', + 'code' => 'refund_order_status', + 'description' => '退款订单状态字典', + 'list' => [ + ['name' => '待创建', 'value' => 0], + ['name' => '处理中', 'value' => 1], + ['name' => '成功', 'value' => 2], + ['name' => '失败', 'value' => 3], + ['name' => '关闭', 'value' => 4], + ], + ], + 'settlement_order_status' => [ + 'name' => '清算订单状态', + 'code' => 'settlement_order_status', + 'description' => '清算订单状态字典', + 'list' => [ + ['name' => '无', 'value' => 0], + ['name' => '待清算', 'value' => 1], + ['name' => '已清算', 'value' => 2], + ['name' => '已冲正', 'value' => 3], + ], + ], + 'callback_status' => [ + 'name' => '回调状态', + 'code' => 'callback_status', + 'description' => '异步回调处理状态字典', + 'list' => [ + ['name' => '待处理', 'value' => 0], + ['name' => '成功', 'value' => 1], + ['name' => '失败', 'value' => 2], + ], + ], + 'callback_type' => [ + 'name' => '回调类型', + 'code' => 'callback_type', + 'description' => '支付回调类型字典', + 'list' => [ + ['name' => '异步通知', 'value' => 0], + ['name' => '同步返回', 'value' => 1], + ], + ], + 'notify_type' => [ + 'name' => '通知类型', + 'code' => 'notify_type', + 'description' => '渠道通知类型字典', + 'list' => [ + ['name' => '异步通知', 'value' => 0], + ['name' => '查单', 'value' => 1], + ], + ], + 'verify_status' => [ + 'name' => '验签状态', + 'code' => 'verify_status', + 'description' => '通知验签状态字典', + 'list' => [ + ['name' => '未知', 'value' => 0], + ['name' => '成功', 'value' => 1], + ['name' => '失败', 'value' => 2], + ], + ], + 'process_status' => [ + 'name' => '处理状态', + 'code' => 'process_status', + 'description' => '通知处理状态字典', + 'list' => [ + ['name' => '待处理', 'value' => 0], + ['name' => '成功', 'value' => 1], + ['name' => '失败', 'value' => 2], + ], + ], + 'ledger_biz_type' => [ + 'name' => '流水业务类型', + 'code' => 'ledger_biz_type', + 'description' => '商户账户流水业务类型字典', + 'list' => [ + ['name' => '支付冻结', 'value' => 0], + ['name' => '支付扣费', 'value' => 1], + ['name' => '支付释放', 'value' => 2], + ['name' => '清算入账', 'value' => 3], + ['name' => '退款冲正', 'value' => 4], + ['name' => '人工调整', 'value' => 5], + ], + ], + 'ledger_event_type' => [ + 'name' => '流水事件类型', + 'code' => 'ledger_event_type', + 'description' => '商户账户流水事件类型字典', + 'list' => [ + ['name' => '创建', 'value' => 0], + ['name' => '成功', 'value' => 1], + ['name' => '失败', 'value' => 2], + ['name' => '冲正', 'value' => 3], + ], + ], + 'ledger_direction' => [ + 'name' => '流水方向', + 'code' => 'ledger_direction', + 'description' => '商户账户流水方向字典', + 'list' => [ + ['name' => '入账', 'value' => 0], + ['name' => '出账', 'value' => 1], + ], + ], +]; diff --git a/config/event.php b/config/event.php index fe3e7dd..5b1291b 100644 --- a/config/event.php +++ b/config/event.php @@ -1,8 +1,5 @@ [ - [app\events\SystemConfig::class, 'reload'], - ], + 'system.config.changed' => [app\listener\SystemConfigChangedListener::class, 'refreshRuntimeCache'], ]; diff --git a/config/jwt.php b/config/jwt.php deleted file mode 100644 index fefb32a..0000000 --- a/config/jwt.php +++ /dev/null @@ -1,19 +0,0 @@ - env('JWT_SECRET', 'mpay-admin-secret-key-change-in-production'), - - // Token 有效期(秒),默认 2 小时 - 'ttl' => (int)env('JWT_TTL', 7200), - - // 加密算法 - 'alg' => env('JWT_ALG', 'HS256'), - - // Token 缓存前缀(用于 Redis 存储) - 'cache_prefix' => env('JWT_CACHE_PREFIX', 'token_'), -]; - diff --git a/config/menu.php b/config/menu.php index 024ef3c..49ebde3 100644 --- a/config/menu.php +++ b/config/menu.php @@ -1,1107 +1,989 @@ '01', - 'parentId' => '0', - 'path' => '/home', - 'name' => 'home', - 'component' => 'home/home', - 'meta' => [ - 'title' => '平台首页', - 'hide' => false, - 'disable' => false, - 'keepAlive' => false, - 'affix' => true, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin', 'common'], - 'svgIcon' => 'home', - 'icon' => '', - 'sort' => 1, - 'type' => 2, + 'admin' => [ + [ + 'id' => '01', + 'parentId' => '0', + 'path' => '/home', + 'name' => 'home', + 'component' => 'home/home', + 'meta' => [ + 'title' => 'home', + 'hide' => false, + 'disable' => false, + 'keepAlive' => false, + 'affix' => true, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'home', + 'icon' => '', + 'sort' => 1, + 'type' => 2, + ], + ], + [ + 'id' => '02', + 'parentId' => '0', + 'path' => '/merchant', + 'name' => 'merchantManagement', + 'meta' => [ + 'title' => 'merchantManagement', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'classify', + 'icon' => '', + 'sort' => 2, + 'type' => 1, + ], + ], + [ + 'id' => '0201', + 'parentId' => '02', + 'path' => '/merchant/merchant-archive', + 'name' => 'merchantArchive', + 'component' => 'merchant/merchant-list/index', + 'meta' => [ + 'title' => 'merchantArchive', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'classify', + 'icon' => '', + 'sort' => 1, + 'type' => 2, + ], + ], + [ + 'id' => '0202', + 'parentId' => '02', + 'path' => '/merchant/merchant-group', + 'name' => 'merchantGroup', + 'component' => 'merchant/merchant-group/index', + 'meta' => [ + 'title' => 'merchantGroup', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'folder-menu', + 'icon' => '', + 'sort' => 2, + 'type' => 2, + ], + ], + [ + 'id' => '03', + 'parentId' => '0', + 'path' => '/channel', + 'name' => 'channelCenter', + 'meta' => [ + 'title' => 'channelCenter', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'data-setting', + 'icon' => '', + 'sort' => 3, + 'type' => 1, + ], + ], + [ + 'id' => '0301', + 'parentId' => '03', + 'path' => '/channel/payment-method', + 'name' => 'paymentMethod', + 'component' => 'channel/payment-method/index', + 'meta' => [ + 'title' => 'paymentMethod', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'data-setting', + 'icon' => '', + 'sort' => 1, + 'type' => 2, + ], + ], + [ + 'id' => '0302', + 'parentId' => '03', + 'path' => '/channel/payment-plugin', + 'name' => 'paymentPlugin', + 'component' => 'channel/payment-plugin/index', + 'meta' => [ + 'title' => 'paymentPlugin', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'functions', + 'icon' => '', + 'sort' => 2, + 'type' => 2, + ], + ], + [ + 'id' => '0303', + 'parentId' => '03', + 'path' => '/channel/payment-plugin-config', + 'name' => 'paymentPluginConfig', + 'component' => 'channel/payment-plugin-conf/index', + 'meta' => [ + 'title' => 'paymentPluginConfig', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'functions', + 'icon' => '', + 'sort' => 3, + 'type' => 2, + ], + ], + [ + 'id' => '0304', + 'parentId' => '03', + 'path' => '/channel/channel-list', + 'name' => 'channelList', + 'component' => 'channel/channel-list/index', + 'meta' => [ + 'title' => 'channelList', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'data', + 'icon' => '', + 'sort' => 4, + 'type' => 2, + ], + ], + [ + 'id' => '0306', + 'parentId' => '03', + 'path' => '/channel/channel-monitor', + 'name' => 'channelDailyStat', + 'component' => 'channel/channel-daily-stat/index', + 'meta' => [ + 'title' => 'channelDailyStat', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'safety', + 'icon' => '', + 'sort' => 6, + 'type' => 2, + ], + ], + [ + 'id' => '04', + 'parentId' => '0', + 'path' => '/route', + 'name' => 'routeCenter', + 'meta' => [ + 'title' => 'routeCenter', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'switch', + 'icon' => '', + 'sort' => 4, + 'type' => 1, + ], + ], + [ + 'id' => '0401', + 'parentId' => '04', + 'path' => '/route/polling-group', + 'name' => 'pollingGroup', + 'component' => 'channel/polling-group/index', + 'meta' => [ + 'title' => 'pollingGroup', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'switch', + 'icon' => '', + 'sort' => 1, + 'type' => 2, + ], + ], + [ + 'id' => '0402', + 'parentId' => '04', + 'path' => '/route/route-compile', + 'name' => 'routeCompile', + 'component' => 'route/route-compile/index', + 'meta' => [ + 'title' => 'routeCompile', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'folder-menu', + 'icon' => '', + 'sort' => 2, + 'type' => 2, + ], + ], + [ + 'id' => '0403', + 'parentId' => '04', + 'path' => '/route/route-preview', + 'name' => 'routePreview', + 'component' => 'channel/route-preview/index', + 'meta' => [ + 'title' => 'routePreview', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'switch', + 'icon' => '', + 'sort' => 3, + 'type' => 2, + ], + ], + [ + 'id' => '05', + 'parentId' => '0', + 'path' => '/transaction', + 'name' => 'transactionCenter', + 'meta' => [ + 'title' => 'transactionCenter', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'table', + 'icon' => '', + 'sort' => 5, + 'type' => 1, + ], + ], + [ + 'id' => '0501', + 'parentId' => '05', + 'path' => '/transaction/pay-order', + 'name' => 'payOrder', + 'component' => 'transaction/pay-order/index', + 'meta' => [ + 'title' => 'payOrder', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'table', + 'icon' => '', + 'sort' => 1, + 'type' => 2, + ], + ], + [ + 'id' => '0502', + 'parentId' => '05', + 'path' => '/transaction/refund-order', + 'name' => 'refundOrder', + 'component' => 'transaction/refund-order/index', + 'meta' => [ + 'title' => 'refundOrder', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'switch', + 'icon' => '', + 'sort' => 2, + 'type' => 2, + ], + ], + [ + 'id' => '0503', + 'parentId' => '05', + 'path' => '/transaction/settlement-order', + 'name' => 'settlementOrder', + 'component' => 'transaction/settlement-order/index', + 'meta' => [ + 'title' => 'settlementOrder', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'financial-statement', + 'icon' => '', + 'sort' => 3, + 'type' => 2, + ], + ], + [ + 'id' => '0504', + 'parentId' => '05', + 'path' => '/transaction/notify-log', + 'name' => 'channelNotifyLog', + 'component' => 'transaction/channel-notify-log/index', + 'meta' => [ + 'title' => 'channelNotifyLog', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'more', + 'icon' => '', + 'sort' => 4, + 'type' => 2, + ], + ], + [ + 'id' => '0505', + 'parentId' => '05', + 'path' => '/transaction/callback-log', + 'name' => 'payCallbackLog', + 'component' => 'transaction/pay-callback-log/index', + 'meta' => [ + 'title' => 'payCallbackLog', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'data-queries', + 'icon' => '', + 'sort' => 5, + 'type' => 2, + ], + ], + [ + 'id' => '06', + 'parentId' => '0', + 'path' => '/funds', + 'name' => 'fundsCenter', + 'meta' => [ + 'title' => 'fundsCenter', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'financial-statement', + 'icon' => '', + 'sort' => 6, + 'type' => 1, + ], + ], + [ + 'id' => '0601', + 'parentId' => '06', + 'path' => '/funds/overview', + 'name' => 'fundsOverview', + 'component' => 'funds/overview/index', + 'meta' => [ + 'title' => 'fundsOverview', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'financial-statement', + 'icon' => '', + 'sort' => 1, + 'type' => 2, + ], + ], + [ + 'id' => '0602', + 'parentId' => '06', + 'path' => '/funds/merchant-account', + 'name' => 'merchantAccount', + 'component' => 'funds/merchant-account/index', + 'meta' => [ + 'title' => 'merchantAccount', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'balance-inquiry', + 'icon' => '', + 'sort' => 2, + 'type' => 2, + ], + ], + [ + 'id' => '0603', + 'parentId' => '06', + 'path' => '/funds/account-ledger', + 'name' => 'accountLedger', + 'component' => 'funds/account-ledger/index', + 'meta' => [ + 'title' => 'accountLedger', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'data-analysis', + 'icon' => '', + 'sort' => 3, + 'type' => 2, + ], + ], + [ + 'id' => '07', + 'parentId' => '0', + 'path' => '/system', + 'name' => 'systemManagement', + 'meta' => [ + 'title' => 'systemManagement', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'set', + 'icon' => '', + 'sort' => 7, + 'type' => 1, + ], + ], + [ + 'id' => '0701', + 'parentId' => '07', + 'path' => '/system/admin-user', + 'name' => 'adminUser', + 'component' => 'system/admin-user/index', + 'meta' => [ + 'title' => 'adminUser', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'user', + 'icon' => '', + 'sort' => 1, + 'type' => 2, + ], + ], + [ + 'id' => '0702', + 'parentId' => '07', + 'path' => '/system/base-config', + 'name' => 'systemConfig', + 'component' => 'system/system-config/index', + 'meta' => [ + 'title' => 'systemConfig', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'set', + 'icon' => '', + 'sort' => 2, + 'type' => 2, + ], + ], + [ + 'id' => '0703', + 'parentId' => '07', + 'path' => '/system/userinfo', + 'name' => 'userinfo', + 'component' => 'system/login-auth/index', + 'meta' => [ + 'title' => 'userinfo', + 'hide' => true, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'user', + 'icon' => '', + 'sort' => 3, + 'type' => 2, + ], + ], + [ + 'id' => '0704', + 'parentId' => '07', + 'path' => '/system/file-management', + 'name' => 'fileManagement', + 'component' => 'system/fileManagement/index', + 'meta' => [ + 'title' => 'fileManagement', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['admin'], + 'svgIcon' => 'folder-menu', + 'icon' => '', + 'sort' => 4, + 'type' => 2, + ], ], ], - - // 2. 收款订单(交易全链路管理) - [ - 'id' => '02', - 'parentId' => '0', - 'path' => '/order', - 'name' => 'order', - 'redirect' => '/order/order-list', - 'meta' => [ - 'title' => '收款订单', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin', 'common'], - 'svgIcon' => 'order', - 'icon' => '', - 'sort' => 2, - 'type' => 1, + 'merchant' => [ + [ + 'id' => '01', + 'parentId' => '0', + 'path' => '/home', + 'name' => 'home', + 'component' => 'home/home', + 'meta' => [ + 'title' => 'home', + 'hide' => false, + 'disable' => false, + 'keepAlive' => false, + 'affix' => true, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['common'], + 'svgIcon' => 'home', + 'icon' => '', + 'sort' => 1, + 'type' => 2, + ], ], - ], - [ - 'id' => '0201', - 'parentId' => '02', - 'path' => '/order/order-list', - 'name' => 'order-list', - 'component' => 'order/order-list/index', - 'meta' => [ - 'title' => '订单管理', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin', 'common'], - 'icon' => 'icon-file', - 'sort' => 1, - 'type' => 2, + [ + 'id' => '02', + 'parentId' => '0', + 'path' => '/channel', + 'name' => 'channelCenter', + 'meta' => [ + 'title' => 'channelCenter', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['common'], + 'svgIcon' => 'data-setting', + 'icon' => '', + 'sort' => 2, + 'type' => 1, + ], ], - ], - [ - 'id' => '0202', - 'parentId' => '02', - 'path' => '/order/refund', - 'name' => 'refund', - 'component' => 'order/refund/index', - 'meta' => [ - 'title' => '退款管理', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin', 'common'], - 'icon' => 'icon-loop', - 'sort' => 2, - 'type' => 2, + [ + 'id' => '0201', + 'parentId' => '02', + 'path' => '/channel/my-channel', + 'name' => 'myChannel', + 'component' => 'channel/my-channel/index', + 'meta' => [ + 'title' => 'myChannel', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['common'], + 'svgIcon' => 'data-setting', + 'icon' => '', + 'sort' => 1, + 'type' => 2, + ], ], - ], - [ - 'id' => '0203', - 'parentId' => '02', - 'path' => '/order/exception', - 'name' => 'exception', - 'component' => 'order/exception/index', - 'meta' => [ - 'title' => '异常订单', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin', 'common'], - 'icon' => 'icon-bug', - 'sort' => 3, - 'type' => 2, + [ + 'id' => '0202', + 'parentId' => '02', + 'path' => '/channel/route-preview', + 'name' => 'routePreview', + 'component' => 'merchant/route-preview/index', + 'meta' => [ + 'title' => 'routePreview', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['common'], + 'svgIcon' => 'switch', + 'icon' => '', + 'sort' => 2, + 'type' => 2, + ], ], - ], - [ - 'id' => '0204', - 'parentId' => '02', - 'path' => '/order/order-log', - 'name' => 'order-log', - 'component' => 'order/order-log/index', - 'meta' => [ - 'title' => '订单日志', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-book', - 'sort' => 4, - 'type' => 2, + [ + 'id' => '0203', + 'parentId' => '02', + 'path' => '/channel/api-credential', + 'name' => 'apiCredential', + 'component' => 'merchant/api-credential/index', + 'meta' => [ + 'title' => 'apiCredential', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['common'], + 'svgIcon' => 'lock-pwd', + 'icon' => '', + 'sort' => 3, + 'type' => 2, + ], ], - ], - [ - 'id' => '0205', - 'parentId' => '02', - 'path' => '/order/export', - 'name' => 'export', - 'component' => 'order/export/index', - 'meta' => [ - 'title' => '导出订单', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-printer', - 'sort' => 5, - 'type' => 2, + [ + 'id' => '03', + 'parentId' => '0', + 'path' => '/transaction', + 'name' => 'transactionCenter', + 'meta' => [ + 'title' => 'transactionCenter', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['common'], + 'svgIcon' => 'table', + 'icon' => '', + 'sort' => 3, + 'type' => 1, + ], ], - ], - [ - 'id' => '0206', - 'parentId' => '02', - 'path' => '/order/user-statistics', - 'name' => 'user-statistics', - 'component' => 'order/user-statistics/index', - 'meta' => [ - 'title' => '支付用户统计', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-user', - 'sort' => 6, - 'type' => 2, + [ + 'id' => '0301', + 'parentId' => '03', + 'path' => '/transaction/pay-order', + 'name' => 'payOrder', + 'component' => 'merchant/pay-order/index', + 'meta' => [ + 'title' => 'payOrder', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['common'], + 'svgIcon' => 'table', + 'icon' => '', + 'sort' => 1, + 'type' => 2, + ], ], - ], - [ - 'id' => '0207', - 'parentId' => '02', - 'path' => '/order/blacklist', - 'name' => 'blacklist', - 'component' => 'order/blacklist/index', - 'meta' => [ - 'title' => '黑名单管理', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-lock', - 'sort' => 7, - 'type' => 2, + [ + 'id' => '0302', + 'parentId' => '03', + 'path' => '/transaction/refund-order', + 'name' => 'refundOrder', + 'component' => 'merchant/refund-order/index', + 'meta' => [ + 'title' => 'refundOrder', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['common'], + 'svgIcon' => 'switch', + 'icon' => '', + 'sort' => 2, + 'type' => 2, + ], ], - ], - - // 3. 商户管理(生命周期全流程) - [ - 'id' => '03', - 'parentId' => '0', - 'path' => '/merchant', - 'name' => 'merchant', - 'redirect' => '/merchant/merchant-list', - 'meta' => [ - 'title' => '商户管理', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin', 'common'], - 'svgIcon' => 'merchant', - 'icon' => '', - 'sort' => 3, - 'type' => 1, + [ + 'id' => '0303', + 'parentId' => '03', + 'path' => '/transaction/settlement-record', + 'name' => 'settlementRecord', + 'component' => 'merchant/settlement-record/index', + 'meta' => [ + 'title' => 'settlementRecord', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['common'], + 'svgIcon' => 'financial-statement', + 'icon' => '', + 'sort' => 3, + 'type' => 2, + ], ], - ], - [ - 'id' => '0301', - 'parentId' => '03', - 'path' => '/merchant/merchant-list', - 'name' => 'merchant-list', - 'component' => 'merchant/merchant-list/index', - 'meta' => [ - 'title' => '商户列表', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin', 'common'], - 'icon' => 'icon-user-group', - 'sort' => 1, - 'type' => 2, + [ + 'id' => '04', + 'parentId' => '0', + 'path' => '/funds', + 'name' => 'fundsCenter', + 'meta' => [ + 'title' => 'fundsCenter', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['common'], + 'svgIcon' => 'financial-statement', + 'icon' => '', + 'sort' => 4, + 'type' => 1, + ], ], - ], - [ - 'id' => '0302', - 'parentId' => '03', - 'path' => '/merchant/audit', - 'name' => 'audit', - 'component' => 'merchant/audit/index', - 'meta' => [ - 'title' => '商户入驻审核', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-idcard', - 'sort' => 2, - 'type' => 2, + [ + 'id' => '0401', + 'parentId' => '04', + 'path' => '/funds/withdrawable-balance', + 'name' => 'withdrawableBalance', + 'component' => 'merchant/withdrawable-balance/index', + 'meta' => [ + 'title' => 'withdrawableBalance', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['common'], + 'svgIcon' => 'balance-inquiry', + 'icon' => '', + 'sort' => 1, + 'type' => 2, + ], ], - ], - [ - 'id' => '0303', - 'parentId' => '03', - 'path' => '/merchant/config', - 'name' => 'config', - 'component' => 'merchant/config/index', - 'meta' => [ - 'title' => '商户配置', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-tool', - 'sort' => 3, - 'type' => 2, + [ + 'id' => '0402', + 'parentId' => '04', + 'path' => '/funds/balance-flow', + 'name' => 'balanceFlow', + 'component' => 'merchant/balance-flow/index', + 'meta' => [ + 'title' => 'balanceFlow', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['common'], + 'svgIcon' => 'data-analysis', + 'icon' => '', + 'sort' => 2, + 'type' => 2, + ], ], - ], - [ - 'id' => '0304', - 'parentId' => '03', - 'path' => '/merchant/group', - 'name' => 'group', - 'component' => 'merchant/group/index', - 'meta' => [ - 'title' => '商户分组', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-folder', - 'sort' => 4, - 'type' => 2, + [ + 'id' => '05', + 'parentId' => '0', + 'path' => '/system', + 'name' => 'basicSettings', + 'meta' => [ + 'title' => 'basicSettings', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['common'], + 'svgIcon' => 'set', + 'icon' => '', + 'sort' => 5, + 'type' => 1, + ], ], - ], - [ - 'id' => '0305', - 'parentId' => '03', - 'path' => '/merchant/funds', - 'name' => 'funds', - 'component' => 'merchant/funds/index', - 'meta' => [ - 'title' => '商户资金管理', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-bar-chart', - 'sort' => 5, - 'type' => 2, + [ + 'id' => '0501', + 'parentId' => '05', + 'path' => '/system/merchant-info', + 'name' => 'merchantInfo', + 'component' => 'merchant/merchant-info/index', + 'meta' => [ + 'title' => 'merchantInfo', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['common'], + 'svgIcon' => 'user', + 'icon' => '', + 'sort' => 1, + 'type' => 2, + ], ], - ], - [ - 'id' => '0306', - 'parentId' => '03', - 'path' => '/merchant/package', - 'name' => 'package', - 'component' => 'merchant/package/index', - 'meta' => [ - 'title' => '商户套餐管理', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-gift', - 'sort' => 6, - 'type' => 2, + [ + 'id' => '0502', + 'parentId' => '05', + 'path' => '/system/login-auth', + 'name' => 'loginAuth', + 'component' => 'merchant/login-auth/index', + 'meta' => [ + 'title' => 'loginAuth', + 'hide' => false, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['common'], + 'svgIcon' => 'lock-pwd', + 'icon' => '', + 'sort' => 2, + 'type' => 2, + ], ], - ], - [ - 'id' => '0307', - 'parentId' => '03', - 'path' => '/merchant/statistics', - 'name' => 'statistics', - 'component' => 'merchant/statistics/index', - 'meta' => [ - 'title' => '商户统计', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-bar-chart', - 'sort' => 7, - 'type' => 2, - ], - ], - - // 4. 财务中心(结算、分账、对账一体化) - [ - 'id' => '04', - 'parentId' => '0', - 'path' => '/finance', - 'name' => 'finance', - 'redirect' => '/finance/settlement', - 'meta' => [ - 'title' => '财务中心', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'svgIcon' => 'financial', - 'icon' => '', - 'sort' => 4, - 'type' => 1, - ], - ], - [ - 'id' => '0401', - 'parentId' => '04', - 'path' => '/finance/settlement', - 'name' => 'settlement', - 'component' => 'finance/settlement/index', - 'meta' => [ - 'title' => '结算管理', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-calendar', - 'sort' => 1, - 'type' => 2, - ], - ], - [ - 'id' => '0402', - 'parentId' => '04', - 'path' => '/finance/batch-settlement', - 'name' => 'batch-settlement', - 'component' => 'finance/batch-settlement/index', - 'meta' => [ - 'title' => '批量结算', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-calendar-clock', - 'sort' => 2, - 'type' => 2, - ], - ], - [ - 'id' => '0403', - 'parentId' => '04', - 'path' => '/finance/settlement-record', - 'name' => 'settlement-record', - 'component' => 'finance/settlement-record/index', - 'meta' => [ - 'title' => '结算记录', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-book', - 'sort' => 3, - 'type' => 2, - ], - ], - [ - 'id' => '0404', - 'parentId' => '04', - 'path' => '/finance/split', - 'name' => 'split', - 'component' => 'finance/split/index', - 'meta' => [ - 'title' => '分账管理', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-branch', - 'sort' => 4, - 'type' => 2, - ], - ], - [ - 'id' => '0405', - 'parentId' => '04', - 'path' => '/finance/fee', - 'name' => 'fee', - 'component' => 'finance/fee/index', - 'meta' => [ - 'title' => '手续费管理', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-tag', - 'sort' => 5, - 'type' => 2, - ], - ], - [ - 'id' => '0406', - 'parentId' => '04', - 'path' => '/finance/reconciliation', - 'name' => 'reconciliation', - 'component' => 'finance/reconciliation/index', - 'meta' => [ - 'title' => '财务对账', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-file', - 'sort' => 6, - 'type' => 2, - ], - ], - [ - 'id' => '0407', - 'parentId' => '04', - 'path' => '/finance/invoice', - 'name' => 'invoice', - 'component' => 'finance/invoice/index', - 'meta' => [ - 'title' => '发票管理', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-file-pdf', - 'sort' => 7, - 'type' => 2, - ], - ], - - // 5. 支付通道(聚合与稳定性保障) - [ - 'id' => '05', - 'parentId' => '0', - 'path' => '/channel', - 'name' => 'channel', - 'redirect' => '/channel/channel-list', - 'meta' => [ - 'title' => '支付通道', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'svgIcon' => 'channel', - 'icon' => '', - 'sort' => 5, - 'type' => 1, - ], - ], - [ - 'id' => '0501', - 'parentId' => '05', - 'path' => '/channel/channel-list', - 'name' => 'channel-list', - 'component' => 'channel/channel-list/index', - 'meta' => [ - 'title' => '通道管理', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-cloud', - 'sort' => 1, - 'type' => 2, - ], - ], - [ - 'id' => '0502', - 'parentId' => '05', - 'path' => '/channel/channel-config', - 'name' => 'channel-config', - 'component' => 'channel/channel-config/index', - 'meta' => [ - 'title' => '通道配置', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-tool', - 'sort' => 2, - 'type' => 2, - ], - ], - [ - 'id' => '0503', - 'parentId' => '05', - 'path' => '/channel/payment-method', - 'name' => 'payment-method', - 'component' => 'channel/payment-method/index', - 'meta' => [ - 'title' => '支付方式', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-relation', - 'sort' => 3, - 'type' => 2, - ], - ], - [ - 'id' => '0504', - 'parentId' => '05', - 'path' => '/channel/plugin', - 'name' => 'plugin', - 'component' => 'channel/plugin/index', - 'meta' => [ - 'title' => '支付插件', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-tool', - 'sort' => 4, - 'type' => 2, - ], - ], - [ - 'id' => '0505', - 'parentId' => '05', - 'path' => '/channel/polling', - 'name' => 'polling', - 'component' => 'channel/polling/index', - 'meta' => [ - 'title' => '通道轮询与容灾', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-loop', - 'sort' => 5, - 'type' => 2, - ], - ], - [ - 'id' => '0506', - 'parentId' => '05', - 'path' => '/channel/monitor', - 'name' => 'monitor', - 'component' => 'channel/monitor/index', - 'meta' => [ - 'title' => '通道监控', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-desktop', - 'sort' => 6, - 'type' => 2, - ], - ], - - // 6. 风控与安全(全链路风险防控) - [ - 'id' => '06', - 'parentId' => '0', - 'path' => '/risk', - 'name' => 'risk', - 'redirect' => '/risk/rule', - 'meta' => [ - 'title' => '风控与安全', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'svgIcon' => 'risk', - 'icon' => '', - 'sort' => 6, - 'type' => 1, - ], - ], - [ - 'id' => '0601', - 'parentId' => '06', - 'path' => '/risk/rule', - 'name' => 'rule', - 'component' => 'risk/rule/index', - 'meta' => [ - 'title' => '风控规则', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-safe', - 'sort' => 1, - 'type' => 2, - ], - ], - [ - 'id' => '0602', - 'parentId' => '06', - 'path' => '/risk/warning', - 'name' => 'warning', - 'component' => 'risk/warning/index', - 'meta' => [ - 'title' => '风控预警', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-notification', - 'sort' => 2, - 'type' => 2, - ], - ], - [ - 'id' => '0603', - 'parentId' => '06', - 'path' => '/risk/disposal', - 'name' => 'disposal', - 'component' => 'risk/disposal/index', - 'meta' => [ - 'title' => '风险处置', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-tool', - 'sort' => 3, - 'type' => 2, - ], - ], - [ - 'id' => '0604', - 'parentId' => '06', - 'path' => '/risk/report', - 'name' => 'report', - 'component' => 'risk/report/index', - 'meta' => [ - 'title' => '风险报告', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-file', - 'sort' => 4, - 'type' => 2, - ], - ], - [ - 'id' => '0605', - 'parentId' => '06', - 'path' => '/risk/security', - 'name' => 'security', - 'component' => 'risk/security/index', - 'meta' => [ - 'title' => '安全配置', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-lock', - 'sort' => 5, - 'type' => 2, - ], - ], - - // 7. 运营分析(数据驱动决策) - [ - 'id' => '08', - 'parentId' => '0', - 'path' => '/analysis', - 'name' => 'analysis', - 'redirect' => '/analysis/transaction', - 'meta' => [ - 'title' => '运营分析', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'svgIcon' => 'analysis', - 'icon' => '', - 'sort' => 7, - 'type' => 1, - ], - ], - [ - 'id' => '0801', - 'parentId' => '08', - 'path' => '/analysis/transaction', - 'name' => 'transaction', - 'component' => 'analysis/transaction/index', - 'meta' => [ - 'title' => '交易分析', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-bar-chart', - 'sort' => 1, - 'type' => 2, - ], - ], - [ - 'id' => '0802', - 'parentId' => '08', - 'path' => '/analysis/merchant-analysis', - 'name' => 'merchant-analysis', - 'component' => 'analysis/merchant-analysis/index', - 'meta' => [ - 'title' => '商户分析', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-bar-chart', - 'sort' => 2, - 'type' => 2, - ], - ], - [ - 'id' => '0803', - 'parentId' => '08', - 'path' => '/analysis/finance-analysis', - 'name' => 'finance-analysis', - 'component' => 'analysis/finance-analysis/index', - 'meta' => [ - 'title' => '财务分析', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-bar-chart', - 'sort' => 3, - 'type' => 2, - ], - ], - [ - 'id' => '0804', - 'parentId' => '08', - 'path' => '/analysis/report', - 'name' => 'report', - 'component' => 'analysis/report/index', - 'meta' => [ - 'title' => '报表中心', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-file', - 'sort' => 4, - 'type' => 2, - ], - ], - - // 8. 系统设置(基础配置与运维) - [ - 'id' => '07', - 'parentId' => '0', - 'path' => '/system', - 'name' => 'system', - 'redirect' => '/system/base-config', - 'meta' => [ - 'title' => '系统设置', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'svgIcon' => 'config', - 'sort' => 8, - 'type' => 1, - ], - ], - [ - 'id' => '0701', - 'parentId' => '07', - 'path' => '/system/base-config', - 'name' => 'base-config', - 'component' => 'system/base-config/index', - 'meta' => [ - 'title' => '基础配置', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-tool', - 'sort' => 1, - 'type' => 2, - ], - ], - [ - 'id' => '0702', - 'parentId' => '07', - 'path' => '/system/notice-config', - 'name' => 'notice-config', - 'component' => 'system/notice-config/index', - 'meta' => [ - 'title' => '通知配置', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-notification', - 'sort' => 2, - 'type' => 2, - ], - ], - [ - 'id' => '0703', - 'parentId' => '07', - 'path' => '/system/log', - 'name' => 'log', - 'component' => 'system/log/index', - 'meta' => [ - 'title' => '日志管理', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-book', - 'sort' => 3, - 'type' => 2, - ], - ], - [ - 'id' => '0704', - 'parentId' => '07', - 'path' => '/system/api', - 'name' => 'api', - 'component' => 'system/api/index', - 'meta' => [ - 'title' => 'API管理', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'icon' => 'icon-tool', - 'sort' => 4, - 'type' => 2, - ], - ], - [ - 'id' => '0705', - 'parentId' => '07', - 'path' => '/system/userinfo', - 'name' => 'userinfo', - 'component' => 'system/userinfo/userinfo', - 'meta' => [ - 'title' => '个人信息', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin', 'common'], - 'icon' => 'icon-user', - 'sort' => 10, - 'type' => 2, - ], - ], - - // 9. 关于项目 - [ - 'id' => '09', - 'parentId' => '0', - 'path' => '/about', - 'name' => 'about', - 'component' => 'about/about', - 'meta' => [ - 'title' => '关于项目', - 'hide' => false, - 'disable' => false, - 'keepAlive' => true, - 'affix' => false, - 'link' => '', - 'iframe' => false, - 'isFull' => false, - 'roles' => ['admin'], - 'svgIcon' => 'about', - 'sort' => 9, - 'type' => 2, + [ + 'id' => '0503', + 'parentId' => '05', + 'path' => '/system/userinfo', + 'name' => 'userinfo', + 'component' => 'system/userinfo/userinfo', + 'meta' => [ + 'title' => 'userinfo', + 'hide' => true, + 'disable' => false, + 'keepAlive' => true, + 'affix' => false, + 'link' => '', + 'iframe' => false, + 'isFull' => false, + 'roles' => ['common'], + 'svgIcon' => 'user', + 'icon' => '', + 'sort' => 3, + 'type' => 2, + ], ], ], ]; diff --git a/config/middleware.php b/config/middleware.php index 8e964ed..ca02871 100644 --- a/config/middleware.php +++ b/config/middleware.php @@ -12,4 +12,5 @@ * @license http://www.opensource.org/licenses/mit-license.php MIT License */ -return []; \ No newline at end of file +return [ +]; diff --git a/config/plugin/webman/redis-queue/app.php b/config/plugin/webman/redis-queue/app.php deleted file mode 100644 index 8f9c426..0000000 --- a/config/plugin/webman/redis-queue/app.php +++ /dev/null @@ -1,4 +0,0 @@ - true, -]; \ No newline at end of file diff --git a/config/plugin/webman/redis-queue/command.php b/config/plugin/webman/redis-queue/command.php deleted file mode 100644 index 8bfe2a1..0000000 --- a/config/plugin/webman/redis-queue/command.php +++ /dev/null @@ -1,7 +0,0 @@ - - * @copyright walkor - * @link http://www.workerman.net/ - * @license http://www.opensource.org/licenses/mit-license.php MIT License - */ - -return [ - 'default' => [ - 'handlers' => [ - [ - 'class' => Monolog\Handler\RotatingFileHandler::class, - 'constructor' => [ - runtime_path() . '/logs/redis-queue/queue.log', - 7, //$maxFiles - Monolog\Logger::DEBUG, - ], - 'formatter' => [ - 'class' => Monolog\Formatter\LineFormatter::class, - 'constructor' => [null, 'Y-m-d H:i:s', true], - ], - ] - ], - ] -]; diff --git a/config/plugin/webman/redis-queue/process.php b/config/plugin/webman/redis-queue/process.php deleted file mode 100644 index 07ec9fb..0000000 --- a/config/plugin/webman/redis-queue/process.php +++ /dev/null @@ -1,11 +0,0 @@ - [ - 'handler' => Webman\RedisQueue\Process\Consumer::class, - 'count' => 8, // 可以设置多进程同时消费 - 'constructor' => [ - // 消费者类目录 - 'consumer_dir' => app_path() . '/jobs' - ] - ] -]; \ No newline at end of file diff --git a/config/plugin/webman/redis-queue/redis.php b/config/plugin/webman/redis-queue/redis.php deleted file mode 100644 index d238b63..0000000 --- a/config/plugin/webman/redis-queue/redis.php +++ /dev/null @@ -1,21 +0,0 @@ - [ - 'host' => 'redis://' . env('REDIS_HOST', '127.0.0.1') . ':' . env('REDIS_PORT', 6379), - 'options' => [ - 'auth' => env('REDIS_PASSWORD', ''), - 'db' => env('QUEUE_REDIS_DATABASE', 0), - 'prefix' => env('QUEUE_REDIS_PREFIX', 'ma:queue:'), - 'max_attempts' => 5, - 'retry_seconds' => 5, - ], - // Connection pool, supports only Swoole or Swow drivers. - 'pool' => [ - 'max_connections' => 5, - 'min_connections' => 1, - 'wait_timeout' => 3, - 'idle_timeout' => 60, - 'heartbeat_interval' => 50, - ] - ], -]; diff --git a/config/plugin/webman/validation/app.php b/config/plugin/webman/validation/app.php index d81dd97..64c2382 100644 --- a/config/plugin/webman/validation/app.php +++ b/config/plugin/webman/validation/app.php @@ -1,6 +1,6 @@ true, diff --git a/config/redis.php b/config/redis.php index 7994e42..a80c146 100644 --- a/config/redis.php +++ b/config/redis.php @@ -1,5 +1,4 @@ 60, 'heartbeat_interval' => 50, ], - ], - 'cache' => [ - 'password' => env('REDIS_PASSWORD', ''), - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'port' => env('REDIS_PORT', 6379), - 'database' => env('CACHE_REDIS_DATABASE', 1), - 'prefix' => 'ma:cache:', - 'pool' => [ - 'max_connections' => 5, - 'min_connections' => 1, - 'wait_timeout' => 3, - 'idle_timeout' => 60, - 'heartbeat_interval' => 50, - ], - ], + ] ]; diff --git a/config/route.php b/config/route.php index 34dd30e..07f7906 100644 --- a/config/route.php +++ b/config/route.php @@ -1,5 +1,4 @@ withHeaders([ @@ -33,7 +35,11 @@ Route::options('[{path:.+}]', function (Request $request){ 'Access-Control-Allow-Headers' => $request->header('access-control-request-headers', '*'), ]); }); -/** - * 关闭默认路由 - */ + +// 关闭默认路由 Route::disableDefaultRoute(); + + + + + diff --git a/config/system-file/dict.json b/config/system-file/dict.json deleted file mode 100644 index 21ad541..0000000 --- a/config/system-file/dict.json +++ /dev/null @@ -1,45 +0,0 @@ -[ - { - "name": "性别", - "code": "gender", - "description": "这是一个性别字典", - "list": [ - { "name": "女", "value": 0 }, - { "name": "男", "value": 1 }, - { "name": "其它", "value": 2 } - ] - }, - { - "name": "状态", - "code": "status", - "description": "状态字段可以用这个", - "list": [ - { "name": "禁用", "value": 0 }, - { "name": "启用", "value": 1 } - ] - }, - { - "name": "岗位", - "code": "post", - "description": "岗位字段", - "list": [ - { "name": "总经理", "value": 1 }, - { "name": "总监", "value": 2 }, - { "name": "人事主管", "value": 3 }, - { "name": "开发部主管", "value": 4 }, - { "name": "普通职员", "value": 5 }, - { "name": "其它", "value": 999 } - ] - }, - { - "name": "任务状态", - "code": "taskStatus", - "description": "任务状态字段可以用它", - "list": [ - { "name": "失败", "value": 0 }, - { "name": "成功", "value": 1 } - ] - } -] - - diff --git a/config/system-file/menu.json b/config/system-file/menu.json deleted file mode 100644 index 99c3290..0000000 --- a/config/system-file/menu.json +++ /dev/null @@ -1,1059 +0,0 @@ -[ - { - "id": "01", - "parentId": "0", - "path": "/home", - "name": "home", - "component": "home/home", - "meta": { - "title": "平台首页", - "hide": false, - "disable": false, - "keepAlive": false, - "affix": true, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin", "common"], - "svgIcon": "home", - "icon": "", - "sort": 1, - "type": 2 - } - }, - { - "id": "02", - "parentId": "0", - "path": "/order", - "name": "order", - "redirect": "/order/order-list", - "meta": { - "title": "收款订单", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin", "common"], - "svgIcon": "order", - "icon": "", - "sort": 2, - "type": 1 - } - }, - { - "id": "0201", - "parentId": "02", - "path": "/order/order-list", - "name": "order-list", - "component": "order/order-list/index", - "meta": { - "title": "订单管理", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin", "common"], - "icon": "icon-file", - "sort": 1, - "type": 2 - } - }, - { - "id": "0202", - "parentId": "02", - "path": "/order/refund", - "name": "refund", - "component": "order/refund/index", - "meta": { - "title": "退款管理", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin", "common"], - "icon": "icon-loop", - "sort": 2, - "type": 2 - } - }, - { - "id": "0203", - "parentId": "02", - "path": "/order/exception", - "name": "exception", - "component": "order/exception/index", - "meta": { - "title": "异常订单", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin", "common"], - "icon": "icon-bug", - "sort": 3, - "type": 2 - } - }, - { - "id": "0204", - "parentId": "02", - "path": "/order/order-log", - "name": "order-log", - "component": "order/order-log/index", - "meta": { - "title": "订单日志", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-book", - "sort": 4, - "type": 2 - } - }, - { - "id": "0205", - "parentId": "02", - "path": "/order/export", - "name": "export", - "component": "order/export/index", - "meta": { - "title": "导出订单", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-printer", - "sort": 5, - "type": 2 - } - }, - { - "id": "0206", - "parentId": "02", - "path": "/order/user-statistics", - "name": "user-statistics", - "component": "order/user-statistics/index", - "meta": { - "title": "支付用户统计", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-user", - "sort": 6, - "type": 2 - } - }, - { - "id": "0207", - "parentId": "02", - "path": "/order/blacklist", - "name": "blacklist", - "component": "order/blacklist/index", - "meta": { - "title": "黑名单管理", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-lock", - "sort": 7, - "type": 2 - } - }, - { - "id": "03", - "parentId": "0", - "path": "/merchant", - "name": "merchant", - "redirect": "/merchant/merchant-list", - "meta": { - "title": "商户管理", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin", "common"], - "svgIcon": "merchant", - "icon": "", - "sort": 3, - "type": 1 - } - }, - { - "id": "0301", - "parentId": "03", - "path": "/merchant/merchant-list", - "name": "merchant-list", - "component": "merchant/merchant-list/index", - "meta": { - "title": "商户列表", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin", "common"], - "icon": "icon-user-group", - "sort": 1, - "type": 2 - } - }, - { - "id": "0302", - "parentId": "03", - "path": "/merchant/audit", - "name": "audit", - "component": "merchant/audit/index", - "meta": { - "title": "商户入驻审核", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-idcard", - "sort": 2, - "type": 2 - } - }, - { - "id": "0303", - "parentId": "03", - "path": "/merchant/config", - "name": "config", - "component": "merchant/config/index", - "meta": { - "title": "商户配置", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-tool", - "sort": 3, - "type": 2 - } - }, - { - "id": "0304", - "parentId": "03", - "path": "/merchant/group", - "name": "group", - "component": "merchant/group/index", - "meta": { - "title": "商户分组", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-folder", - "sort": 4, - "type": 2 - } - }, - { - "id": "0305", - "parentId": "03", - "path": "/merchant/funds", - "name": "funds", - "component": "merchant/funds/index", - "meta": { - "title": "商户资金管理", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-bar-chart", - "sort": 5, - "type": 2 - } - }, - { - "id": "0306", - "parentId": "03", - "path": "/merchant/package", - "name": "package", - "component": "merchant/package/index", - "meta": { - "title": "商户套餐管理", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-gift", - "sort": 6, - "type": 2 - } - }, - { - "id": "0307", - "parentId": "03", - "path": "/merchant/statistics", - "name": "statistics", - "component": "merchant/statistics/index", - "meta": { - "title": "商户统计", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-bar-chart", - "sort": 7, - "type": 2 - } - }, - { - "id": "04", - "parentId": "0", - "path": "/finance", - "name": "finance", - "redirect": "/finance/settlement", - "meta": { - "title": "财务中心", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "svgIcon": "financial", - "icon": "", - "sort": 4, - "type": 1 - } - }, - { - "id": "0401", - "parentId": "04", - "path": "/finance/settlement", - "name": "settlement", - "component": "finance/settlement/index", - "meta": { - "title": "结算管理", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-calendar", - "sort": 1, - "type": 2 - } - }, - { - "id": "0402", - "parentId": "04", - "path": "/finance/batch-settlement", - "name": "batch-settlement", - "component": "finance/batch-settlement/index", - "meta": { - "title": "批量结算", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-calendar-clock", - "sort": 2, - "type": 2 - } - }, - { - "id": "0403", - "parentId": "04", - "path": "/finance/settlement-record", - "name": "settlement-record", - "component": "finance/settlement-record/index", - "meta": { - "title": "结算记录", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-book", - "sort": 3, - "type": 2 - } - }, - { - "id": "0404", - "parentId": "04", - "path": "/finance/split", - "name": "split", - "component": "finance/split/index", - "meta": { - "title": "分账管理", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-branch", - "sort": 4, - "type": 2 - } - }, - { - "id": "0405", - "parentId": "04", - "path": "/finance/fee", - "name": "fee", - "component": "finance/fee/index", - "meta": { - "title": "手续费管理", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-tag", - "sort": 5, - "type": 2 - } - }, - { - "id": "0406", - "parentId": "04", - "path": "/finance/reconciliation", - "name": "reconciliation", - "component": "finance/reconciliation/index", - "meta": { - "title": "财务对账", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-file", - "sort": 6, - "type": 2 - } - }, - { - "id": "0407", - "parentId": "04", - "path": "/finance/invoice", - "name": "invoice", - "component": "finance/invoice/index", - "meta": { - "title": "发票管理", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-file-pdf", - "sort": 7, - "type": 2 - } - }, - { - "id": "05", - "parentId": "0", - "path": "/channel", - "name": "channel", - "redirect": "/channel/channel-list", - "meta": { - "title": "支付通道", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "svgIcon": "channel", - "icon": "", - "sort": 5, - "type": 1 - } - }, - { - "id": "0501", - "parentId": "05", - "path": "/channel/channel-list", - "name": "channel-list", - "component": "channel/channel-list/index", - "meta": { - "title": "通道管理", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-cloud", - "sort": 1, - "type": 2 - } - }, - { - "id": "0502", - "parentId": "05", - "path": "/channel/channel-config", - "name": "channel-config", - "component": "channel/channel-config/index", - "meta": { - "title": "通道配置", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-tool", - "sort": 2, - "type": 2 - } - }, - { - "id": "0503", - "parentId": "05", - "path": "/channel/payment-method", - "name": "payment-method", - "component": "channel/payment-method/index", - "meta": { - "title": "支付方式", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-relation", - "sort": 3, - "type": 2 - } - }, - { - "id": "0504", - "parentId": "05", - "path": "/channel/plugin", - "name": "plugin", - "component": "channel/plugin/index", - "meta": { - "title": "支付插件", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-tool", - "sort": 4, - "type": 2 - } - }, - { - "id": "0505", - "parentId": "05", - "path": "/channel/polling", - "name": "polling", - "component": "channel/polling/index", - "meta": { - "title": "通道轮询与容灾", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-loop", - "sort": 5, - "type": 2 - } - }, - { - "id": "0506", - "parentId": "05", - "path": "/channel/monitor", - "name": "monitor", - "component": "channel/monitor/index", - "meta": { - "title": "通道监控", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-desktop", - "sort": 6, - "type": 2 - } - }, - { - "id": "06", - "parentId": "0", - "path": "/risk", - "name": "risk", - "redirect": "/risk/rule", - "meta": { - "title": "风控与安全", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "svgIcon": "risk", - "icon": "", - "sort": 6, - "type": 1 - } - }, - { - "id": "0601", - "parentId": "06", - "path": "/risk/rule", - "name": "rule", - "component": "risk/rule/index", - "meta": { - "title": "风控规则", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-safe", - "sort": 1, - "type": 2 - } - }, - { - "id": "0602", - "parentId": "06", - "path": "/risk/warning", - "name": "warning", - "component": "risk/warning/index", - "meta": { - "title": "风控预警", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-notification", - "sort": 2, - "type": 2 - } - }, - { - "id": "0603", - "parentId": "06", - "path": "/risk/disposal", - "name": "disposal", - "component": "risk/disposal/index", - "meta": { - "title": "风险处置", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-tool", - "sort": 3, - "type": 2 - } - }, - { - "id": "0604", - "parentId": "06", - "path": "/risk/report", - "name": "risk-report", - "component": "risk/report/index", - "meta": { - "title": "风险报告", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-file", - "sort": 4, - "type": 2 - } - }, - { - "id": "0605", - "parentId": "06", - "path": "/risk/security", - "name": "security", - "component": "risk/security/index", - "meta": { - "title": "安全配置", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-lock", - "sort": 5, - "type": 2 - } - }, - { - "id": "08", - "parentId": "0", - "path": "/analysis", - "name": "analysis", - "redirect": "/analysis/transaction", - "meta": { - "title": "运营分析", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "svgIcon": "analysis", - "icon": "", - "sort": 7, - "type": 1 - } - }, - { - "id": "0801", - "parentId": "08", - "path": "/analysis/transaction", - "name": "transaction", - "component": "analysis/transaction/index", - "meta": { - "title": "交易分析", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-bar-chart", - "sort": 1, - "type": 2 - } - }, - { - "id": "0802", - "parentId": "08", - "path": "/analysis/merchant-analysis", - "name": "merchant-analysis", - "component": "analysis/merchant-analysis/index", - "meta": { - "title": "商户分析", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-bar-chart", - "sort": 2, - "type": 2 - } - }, - { - "id": "0803", - "parentId": "08", - "path": "/analysis/finance-analysis", - "name": "finance-analysis", - "component": "analysis/finance-analysis/index", - "meta": { - "title": "财务分析", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-bar-chart", - "sort": 3, - "type": 2 - } - }, - { - "id": "0804", - "parentId": "08", - "path": "/analysis/report", - "name": "analysis-report", - "component": "analysis/report/index", - "meta": { - "title": "报表中心", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-file", - "sort": 4, - "type": 2 - } - }, - { - "id": "07", - "parentId": "0", - "path": "/system", - "name": "system", - "redirect": "/system/base-config", - "meta": { - "title": "系统设置", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "svgIcon": "config", - "sort": 8, - "type": 1 - } - }, - { - "id": "0701", - "parentId": "07", - "path": "/system/base-config", - "name": "base-config", - "component": "system/base-config/index", - "meta": { - "title": "基础配置", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-tool", - "sort": 1, - "type": 2 - } - }, - { - "id": "0702", - "parentId": "07", - "path": "/system/notice-config", - "name": "notice-config", - "component": "system/notice-config/index", - "meta": { - "title": "通知配置", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-notification", - "sort": 2, - "type": 2 - } - }, - { - "id": "0703", - "parentId": "07", - "path": "/system/log", - "name": "log", - "component": "system/log/index", - "meta": { - "title": "日志管理", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-book", - "sort": 3, - "type": 2 - } - }, - { - "id": "0704", - "parentId": "07", - "path": "/system/api", - "name": "api", - "component": "system/api/index", - "meta": { - "title": "API管理", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "icon": "icon-tool", - "sort": 4, - "type": 2 - } - }, - { - "id": "0705", - "parentId": "07", - "path": "/system/userinfo", - "name": "userinfo", - "component": "system/userinfo/userinfo", - "meta": { - "title": "个人信息", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin", "common"], - "icon": "icon-user", - "sort": 10, - "type": 2 - } - }, - { - "id": "09", - "parentId": "0", - "path": "/about", - "name": "about", - "component": "about/about", - "meta": { - "title": "关于项目", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin"], - "svgIcon": "about", - "sort": 9, - "type": 2 - } - } - ] \ No newline at end of file diff --git a/config/system-file/menu.md b/config/system-file/menu.md deleted file mode 100644 index 4b21f81..0000000 --- a/config/system-file/menu.md +++ /dev/null @@ -1,137 +0,0 @@ -# 系统菜单配置 - -## 配置说明 -系统菜单基于路由配置实现,以下是完整的菜单路由配置项说明,所有配置项均为 JSON 格式,可直接用于前端路由解析。 - -## 核心配置结构 -```json -{ - "id": "01", - "parentId": "0", - "path": "/home", - "name": "home", - "component": "home/home", - "meta": { - "title": "home", - "hide": false, - "disable": false, - "keepAlive": false, - "affix": true, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin", "common"], - "svgIcon": "home", - "icon": "", - "sort": 1, - "type": 2 - }, - "children": null -} -``` - -## 字段详细说明 -| 一级字段 | 类型 | 必填 | 说明 | -|----------|--------|------|----------------------------------------------------------------------| -| `id` | string | 是 | 路由唯一标识,建议按「层级+序号」命名(如 01=首页,0201=订单管理)| -| `parentId` | string | 是 | 父路由ID,顶层路由固定为 `0`,子路由对应父路由的 `id` | -| `path` | string | 是 | 路由访问路径(如 `/home`、`/order/order-list`)| -| `name` | string | 是 | 路由名称,需与组件名/路径名保持一致,用于路由跳转标识 | -| `component` | string | 否 | 组件文件路径(基于 `src/views` 目录),如 `home/home` 对应 `src/views/home/home.vue`;目录级路由可省略 | -| `meta` | object | 是 | 路由元信息,包含菜单展示、权限、样式等核心配置 | -| `children` | array | 否 | 子路由列表,目录级路由(`type:1`)需配置,菜单级路由(`type:2`)默认为 `null` | - -### `meta` 子字段说明 -| 子字段 | 类型 | 必填 | 默认值 | 说明 | -|-----------|---------|------|--------|----------------------------------------------------------------------| -| `title` | string | 是 | - | 菜单显示标题:
1. 填写国际化key(如 `home`),自动匹配多语言;
2. 无对应key则直接展示文字 | -| `hide` | boolean | 否 | false | 是否隐藏菜单:
✅ true = 不显示在侧边栏,但可正常访问;
❌ false = 正常显示 | -| `disable` | boolean | 否 | false | 是否停用路由:
✅ true = 不显示+不可访问;
❌ false = 正常可用 | -| `keepAlive` | boolean | 否 | false | 是否缓存组件:
✅ true = 切换路由不销毁组件;
❌ false = 切换后销毁 | -| `affix` | boolean | 否 | false | 是否固定在标签栏:
✅ true = 标签栏无关闭按钮;
❌ false = 可关闭 | -| `link` | string | 否 | "" | 外链地址,填写后路由跳转至该地址(优先级高于 `component`)| -| `iframe` | boolean | 否 | false | 是否内嵌外链:
✅ true = 在页面内以iframe展示 `link` 地址;
❌ false = 跳转新页面 | -| `isFull` | boolean | 否 | false | 是否全屏显示:
✅ true = 菜单页面占满整个视口;
❌ false = 保留侧边栏/头部 | -| `roles` | array | 否 | [] | 路由权限角色:
如 `["admin", "common"]`,仅对应角色可访问该菜单 | -| `svgIcon` | string | 否 | "" | SVG菜单图标:
优先级高于 `icon`,取值为 `src/assets/svgs` 目录下的SVG文件名 | -| `icon` | string | 否 | "" | 普通图标:
默认使用 Arco Design 图标库,填写图标名(如 `icon-file`)即可 | -| `sort` | number | 否 | 0 | 菜单排序:数值越小,展示越靠前 | -| `type` | number | 是 | - | 路由类型:
1 = 目录(仅作为父级,无组件);
2 = 菜单(可访问的页面);
3 = 按钮(权限控制用) | - -## 配置示例 -### 1. 顶层菜单(首页) -```json -{ - "id": "01", - "parentId": "0", - "path": "/home", - "name": "home", - "component": "home/home", - "meta": { - "title": "平台首页", - "hide": false, - "disable": false, - "keepAlive": false, - "affix": true, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin", "common"], - "svgIcon": "home", - "icon": "", - "sort": 1, - "type": 2 - }, - "children": null -} -``` - -### 2. 目录级路由(收款订单) -```json -{ - "id": "02", - "parentId": "0", - "path": "/order", - "name": "order", - "redirect": "/order/order-list", - "meta": { - "title": "收款订单", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin", "common"], - "svgIcon": "order", - "icon": "", - "sort": 2, - "type": 1 - }, - "children": [ - { - "id": "0201", - "parentId": "02", - "path": "/order/order-list", - "name": "order-list", - "component": "order/order-list/index", - "meta": { - "title": "订单管理", - "hide": false, - "disable": false, - "keepAlive": true, - "affix": false, - "link": "", - "iframe": false, - "isFull": false, - "roles": ["admin", "common"], - "icon": "icon-file", - "sort": 1, - "type": 2 - }, - "children": null - } - ] -} -``` diff --git a/config/system_config.php b/config/system_config.php new file mode 100644 index 0000000..c5b753e --- /dev/null +++ b/config/system_config.php @@ -0,0 +1,608 @@ + [ + 'title' => '基础配置', + 'icon' => 'icon-settings', + 'description' => '站点基础信息、站点 URL、页面默认值与展示文案。', + 'sort' => 1, + 'disabled' => false, + 'rules' => [ + [ + 'type' => 'input', + 'field' => 'site_name', + 'title' => '站点名称', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入站点名称', + ], + 'validate' => [ + ['required' => true, 'message' => '站点名称不能为空'], + ], + ], + [ + 'type' => 'textarea', + 'field' => 'site_description', + 'title' => '站点描述', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入站点描述', + 'autoSize' => [ + 'minRows' => 3, + 'maxRows' => 6, + ], + ], + ], + [ + 'type' => 'upload', + 'field' => 'site_logo', + 'title' => '站点 Logo', + 'value' => '', + 'props' => [ + 'fileUpload' => [ + 'scene' => FileConstant::SCENE_IMAGE, + 'visibility' => FileConstant::VISIBILITY_PUBLIC, + 'accept' => '.jpg,.jpeg,.png,.gif,.webp,.bmp,.svg', + 'listType' => 'picture-card', + 'showFileList' => true, + 'imagePreview' => true, + 'limit' => 1, + ], + 'tip' => '建议上传清晰的图片 Logo,推荐使用 PNG 或 SVG 格式。', + ], + ], + [ + 'type' => 'input', + 'field' => 'site_url', + 'title' => '站点 URL', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入站点 URL,例如 https://pay.example.com', + ], + 'validate' => [ + ['required' => true, 'message' => '站点 URL 不能为空'], + ], + ], + [ + 'type' => 'input', + 'field' => 'site_icp', + 'title' => '备案号', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入备案号', + ], + ], + [ + 'type' => 'select', + 'field' => 'default_page_size', + 'title' => '默认分页条数', + 'value' => '20', + 'props' => [ + 'placeholder' => '请选择默认分页条数', + ], + 'options' => [ + ['label' => '10', 'value' => '10'], + ['label' => '20', 'value' => '20'], + ['label' => '50', 'value' => '50'], + ['label' => '100', 'value' => '100'], + ], + ], + ], + ], + 'storage' => [ + 'title' => '存储配置', + 'icon' => 'icon-storage', + 'description' => '文件上传、图片素材、证书和对象存储的统一配置。', + 'sort' => 2, + 'disabled' => false, + 'rules' => [ + [ + 'type' => 'radio', + 'field' => 'file_storage_default_engine', + 'title' => '默认存储引擎', + 'value' => '1', + 'options' => [ + ['label' => '本地存储', 'value' => (string) FileConstant::STORAGE_LOCAL], + ['label' => '阿里云 OSS', 'value' => (string) FileConstant::STORAGE_ALIYUN_OSS], + ['label' => '腾讯云 COS', 'value' => (string) FileConstant::STORAGE_TENCENT_COS], + ], + 'control' => [ + [ + 'rule' => [ + 'file_storage_local_public_base_url', + 'file_storage_local_public_dir', + 'file_storage_local_private_dir', + ], + 'value' => (string) FileConstant::STORAGE_LOCAL, + 'method' => 'display', + ], + [ + 'rule' => [ + 'file_storage_aliyun_oss_region', + 'file_storage_aliyun_oss_endpoint', + 'file_storage_aliyun_oss_bucket', + 'file_storage_aliyun_oss_access_key_id', + 'file_storage_aliyun_oss_access_key_secret', + 'file_storage_aliyun_oss_public_domain', + ], + 'value' => (string) FileConstant::STORAGE_ALIYUN_OSS, + 'method' => 'display', + ], + [ + 'rule' => [ + 'file_storage_tencent_cos_region', + 'file_storage_tencent_cos_bucket', + 'file_storage_tencent_cos_secret_id', + 'file_storage_tencent_cos_secret_key', + 'file_storage_tencent_cos_public_domain', + ], + 'value' => (string) FileConstant::STORAGE_TENCENT_COS, + 'method' => 'display', + ], + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_upload_max_size_mb', + 'title' => '上传大小限制', + 'value' => '20', + 'props' => [ + 'placeholder' => '请输入上传大小限制,单位 MB', + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_remote_download_limit_mb', + 'title' => '远程下载限制', + 'value' => '10', + 'props' => [ + 'placeholder' => '请输入远程下载限制,单位 MB', + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_allowed_extensions', + 'title' => '允许扩展名', + 'value' => 'jpg,jpeg,png,gif,webp,bmp,svg,pem,crt,cer,key,p12,pfx,txt,log,csv,json,xml,md,ini,conf,yaml,yml', + 'props' => [ + 'placeholder' => '请输入允许上传的扩展名,英文逗号分隔', + ], + ], + [ + 'type' => 'a-divider', + 'field' => '__storage_engine_divider__', + 'title' => '存储引擎配置', + 'value' => '', + 'props' => [ + 'orientation' => 'left', + 'margin' => 16, + ], + 'children' => [ + '存储引擎配置', + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_local_public_base_url', + 'title' => '本地公开 Base URL', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入本地公开访问前缀,留空时自动使用站点 URL', + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_local_public_dir', + 'title' => '本地公开目录', + 'value' => 'storage/uploads', + 'props' => [ + 'placeholder' => '请输入本地公开目录,例如 storage/uploads', + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_local_private_dir', + 'title' => '本地私有目录', + 'value' => 'storage/private', + 'props' => [ + 'placeholder' => '请输入本地私有目录,例如 storage/private', + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_aliyun_oss_region', + 'title' => '阿里云 OSS Region', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入阿里云 OSS Region,例如 oss-cn-hangzhou', + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_aliyun_oss_endpoint', + 'title' => '阿里云 OSS Endpoint', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入阿里云 OSS Endpoint', + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_aliyun_oss_bucket', + 'title' => '阿里云 OSS Bucket', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入阿里云 OSS Bucket', + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_aliyun_oss_access_key_id', + 'title' => '阿里云 OSS Access Key ID', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入阿里云 OSS Access Key ID', + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_aliyun_oss_access_key_secret', + 'title' => '阿里云 OSS Access Key Secret', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入阿里云 OSS Access Key Secret', + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_aliyun_oss_public_domain', + 'title' => '阿里云 OSS 公开域名', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入阿里云 OSS 公开域名,可选', + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_tencent_cos_region', + 'title' => '腾讯云 COS Region', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入腾讯云 COS Region,例如 ap-shanghai', + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_tencent_cos_bucket', + 'title' => '腾讯云 COS Bucket', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入腾讯云 COS Bucket', + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_tencent_cos_secret_id', + 'title' => '腾讯云 COS SecretId', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入腾讯云 COS SecretId', + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_tencent_cos_secret_key', + 'title' => '腾讯云 COS SecretKey', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入腾讯云 COS SecretKey', + ], + ], + [ + 'type' => 'input', + 'field' => 'file_storage_tencent_cos_public_domain', + 'title' => '腾讯云 COS 公开域名', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入腾讯云 COS 公开域名,可选', + ], + ], + ], + ], + 'merchant' => [ + 'title' => '商户配置', + 'icon' => 'icon-user-group', + 'description' => '商户后台展示、资料和辅助能力配置。', + 'sort' => 3, + 'disabled' => false, + 'rules' => [ + [ + 'type' => 'input', + 'field' => 'merchant_service_name', + 'title' => '客服名称', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入客服名称', + ], + ], + [ + 'type' => 'input', + 'field' => 'merchant_service_phone', + 'title' => '客服电话', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入客服电话', + ], + ], + [ + 'type' => 'input', + 'field' => 'merchant_service_email', + 'title' => '客服邮箱', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入客服邮箱', + ], + ], + [ + 'type' => 'textarea', + 'field' => 'merchant_notice', + 'title' => '商户公告', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入商户公告', + 'autoSize' => [ + 'minRows' => 3, + 'maxRows' => 6, + ], + ], + ], + ], + ], + 'channel' => [ + 'title' => '通道配置', + 'icon' => 'icon-apps', + 'description' => '通道调度、同步和基础运维参数。', + 'sort' => 4, + 'disabled' => false, + 'rules' => [ + [ + 'type' => 'select', + 'field' => 'channel_sync_interval', + 'title' => '同步间隔', + 'value' => '10', + 'props' => [ + 'placeholder' => '请选择同步间隔', + ], + 'options' => [ + ['label' => '5 分钟', 'value' => '5'], + ['label' => '10 分钟', 'value' => '10'], + ['label' => '30 分钟', 'value' => '30'], + ['label' => '60 分钟', 'value' => '60'], + ], + ], + [ + 'type' => 'input', + 'field' => 'channel_default_timeout', + 'title' => '默认超时时间', + 'value' => '60', + 'props' => [ + 'placeholder' => '请输入默认超时时间,单位秒', + ], + ], + [ + 'type' => 'input', + 'field' => 'channel_retry_limit', + 'title' => '同步重试次数', + 'value' => '3', + 'props' => [ + 'placeholder' => '请输入同步重试次数', + ], + ], + [ + 'type' => 'textarea', + 'field' => 'channel_notice', + 'title' => '通道说明', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入通道说明', + 'autoSize' => [ + 'minRows' => 3, + 'maxRows' => 6, + ], + ], + ], + ], + ], + 'payment' => [ + 'title' => '支付配置', + 'icon' => 'icon-safe', + 'description' => '支付时效、通知重试和支付流程相关参数。', + 'sort' => 5, + 'disabled' => false, + 'rules' => [ + [ + 'type' => 'input', + 'field' => 'pay_order_expire_minutes', + 'title' => '订单有效期', + 'value' => '30', + 'props' => [ + 'placeholder' => '请输入订单有效期,单位分钟', + ], + 'validate' => [ + ['required' => true, 'message' => '订单有效期不能为空'], + ], + ], + [ + 'type' => 'input', + 'field' => 'pay_notify_retry_limit', + 'title' => '通知重试次数', + 'value' => '3', + 'props' => [ + 'placeholder' => '请输入通知重试次数', + ], + ], + [ + 'type' => 'input', + 'field' => 'pay_notify_retry_interval', + 'title' => '通知重试间隔', + 'value' => '10', + 'props' => [ + 'placeholder' => '请输入通知重试间隔,单位分钟', + ], + ], + [ + 'type' => 'input', + 'field' => 'pay_callback_timeout_seconds', + 'title' => '回调超时', + 'value' => '60', + 'props' => [ + 'placeholder' => '请输入回调超时时间,单位秒', + ], + ], + ], + ], + 'notice' => [ + 'title' => '通知配置', + 'icon' => 'icon-notification', + 'description' => '通知开关、频率和失败重试策略。', + 'sort' => 6, + 'disabled' => false, + 'rules' => [ + [ + 'type' => 'select', + 'field' => 'notice_enabled', + 'title' => '通知开关', + 'value' => '1', + 'props' => [ + 'placeholder' => '请选择通知开关', + ], + 'options' => [ + ['label' => '禁用', 'value' => '0'], + ['label' => '启用', 'value' => '1'], + ], + ], + [ + 'type' => 'input', + 'field' => 'notice_retry_limit', + 'title' => '通知重试次数', + 'value' => '3', + 'props' => [ + 'placeholder' => '请输入通知重试次数', + ], + ], + [ + 'type' => 'input', + 'field' => 'notice_retry_interval', + 'title' => '通知重试间隔', + 'value' => '10', + 'props' => [ + 'placeholder' => '请输入通知重试间隔,单位分钟', + ], + ], + [ + 'type' => 'input', + 'field' => 'notice_webhook_url', + 'title' => '通知地址', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入通知地址', + ], + ], + ], + ], + 'risk' => [ + 'title' => '风控配置', + 'icon' => 'icon-fire', + 'description' => '基础风控阈值和限制策略。', + 'sort' => 7, + 'disabled' => false, + 'rules' => [ + [ + 'type' => 'select', + 'field' => 'risk_enabled', + 'title' => '风控开关', + 'value' => '1', + 'props' => [ + 'placeholder' => '请选择风控开关', + ], + 'options' => [ + ['label' => '禁用', 'value' => '0'], + ['label' => '启用', 'value' => '1'], + ], + ], + [ + 'type' => 'input', + 'field' => 'risk_ip_limit', + 'title' => 'IP 限制阈值', + 'value' => '20', + 'props' => [ + 'placeholder' => '请输入 IP 限制阈值', + ], + ], + [ + 'type' => 'input', + 'field' => 'risk_order_limit', + 'title' => '订单限制阈值', + 'value' => '100', + 'props' => [ + 'placeholder' => '请输入订单限制阈值', + ], + ], + [ + 'type' => 'textarea', + 'field' => 'risk_notice', + 'title' => '风控说明', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入风控说明', + 'autoSize' => [ + 'minRows' => 3, + 'maxRows' => 6, + ], + ], + ], + ], + ], + 'other' => [ + 'title' => '其他配置', + 'icon' => 'icon-folder', + 'description' => '未归类到其它模块的补充配置。', + 'sort' => 8, + 'disabled' => false, + 'rules' => [ + [ + 'type' => 'input', + 'field' => 'default_timezone', + 'title' => '默认时区', + 'value' => 'Asia/Shanghai', + 'props' => [ + 'placeholder' => '请输入默认时区', + ], + ], + [ + 'type' => 'input', + 'field' => 'default_currency', + 'title' => '默认币种', + 'value' => 'CNY', + 'props' => [ + 'placeholder' => '请输入默认币种', + ], + ], + [ + 'type' => 'textarea', + 'field' => 'system_note', + 'title' => '系统备注', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入系统备注', + 'autoSize' => [ + 'minRows' => 3, + 'maxRows' => 6, + ], + ], + ], + ], + ], +]; diff --git a/database/20260320_align_current_schema.sql b/database/20260320_align_current_schema.sql deleted file mode 100644 index 0405926..0000000 --- a/database/20260320_align_current_schema.sql +++ /dev/null @@ -1,169 +0,0 @@ --- Current schema alignment for the updated merchant / merchant-app model. --- Target DB: MySQL 5.7+ - -SET NAMES utf8mb4; -SET FOREIGN_KEY_CHECKS = 0; - --- ========================================================= --- 1. Expand ma_mer for admin + merchant backend scenarios --- ========================================================= -ALTER TABLE `ma_mer` - ADD COLUMN `merchant_short_name` varchar(60) NOT NULL DEFAULT '' COMMENT '商户简称' AFTER `merchant_name`, - ADD COLUMN `merchant_type` varchar(20) NOT NULL DEFAULT 'company' COMMENT '商户类型:company/individual/other' AFTER `merchant_short_name`, - ADD COLUMN `group_code` varchar(32) NOT NULL DEFAULT '' COMMENT '商户分组编码' AFTER `merchant_type`, - ADD COLUMN `legal_name` varchar(100) NOT NULL DEFAULT '' COMMENT '法人姓名' AFTER `funds_mode`, - ADD COLUMN `contact_name` varchar(50) NOT NULL DEFAULT '' COMMENT '联系人姓名' AFTER `legal_name`, - ADD COLUMN `contact_phone` varchar(20) NOT NULL DEFAULT '' COMMENT '联系人手机号' AFTER `contact_name`, - ADD COLUMN `contact_email` varchar(100) NOT NULL DEFAULT '' COMMENT '联系人邮箱' AFTER `contact_phone`, - ADD COLUMN `website` varchar(255) NOT NULL DEFAULT '' COMMENT '商户官网' AFTER `contact_email`, - ADD COLUMN `province` varchar(50) NOT NULL DEFAULT '' COMMENT '省份' AFTER `website`, - ADD COLUMN `city` varchar(50) NOT NULL DEFAULT '' COMMENT '城市' AFTER `province`, - ADD COLUMN `address` varchar(255) NOT NULL DEFAULT '' COMMENT '详细地址' AFTER `city`, - ADD COLUMN `callback_domain` varchar(255) NOT NULL DEFAULT '' COMMENT '回调域名' AFTER `address`, - ADD COLUMN `callback_ip_whitelist` text COMMENT '回调IP白名单' AFTER `callback_domain`, - ADD COLUMN `risk_level` varchar(20) NOT NULL DEFAULT 'standard' COMMENT '风控等级:low/standard/high' AFTER `callback_ip_whitelist`, - ADD COLUMN `settlement_mode` varchar(20) NOT NULL DEFAULT 'auto' COMMENT '结算方式:auto/manual' AFTER `risk_level`, - ADD COLUMN `settlement_cycle` varchar(20) NOT NULL DEFAULT 't1' COMMENT '结算周期:d0/t1/manual' AFTER `settlement_mode`, - ADD COLUMN `settlement_account_name` varchar(100) NOT NULL DEFAULT '' COMMENT '结算账户名' AFTER `settlement_cycle`, - ADD COLUMN `settlement_account_no` varchar(100) NOT NULL DEFAULT '' COMMENT '结算账户号' AFTER `settlement_account_name`, - ADD COLUMN `settlement_bank_name` varchar(100) NOT NULL DEFAULT '' COMMENT '结算银行名称' AFTER `settlement_account_no`, - ADD COLUMN `settlement_bank_branch` varchar(100) NOT NULL DEFAULT '' COMMENT '结算支行名称' AFTER `settlement_bank_name`, - ADD COLUMN `single_limit` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '单笔限额(元)' AFTER `settlement_bank_branch`, - ADD COLUMN `daily_limit` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '日限额(元)' AFTER `single_limit`, - ADD COLUMN `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注' AFTER `daily_limit`, - ADD COLUMN `extra` json DEFAULT NULL COMMENT '扩展字段(JSON)' AFTER `remark`; - -ALTER TABLE `ma_mer` - ADD KEY `idx_group_code` (`group_code`), - ADD KEY `idx_contact_phone` (`contact_phone`), - ADD KEY `idx_status` (`status`); - --- ========================================================= --- 2. Expand ma_pay_app for merchant app backend settings --- ========================================================= -ALTER TABLE `ma_pay_app` - ADD COLUMN `package_code` varchar(32) NOT NULL DEFAULT '' COMMENT '商户套餐编码' AFTER `app_name`, - ADD COLUMN `notify_url` varchar(255) NOT NULL DEFAULT '' COMMENT '异步通知地址' AFTER `package_code`, - ADD COLUMN `return_url` varchar(255) NOT NULL DEFAULT '' COMMENT '同步跳转地址' AFTER `notify_url`, - ADD COLUMN `callback_mode` varchar(20) NOT NULL DEFAULT 'server' COMMENT '回调模式:server/server+page/manual' AFTER `return_url`, - ADD COLUMN `sign_type` varchar(20) NOT NULL DEFAULT 'md5' COMMENT '签名方式' AFTER `callback_mode`, - ADD COLUMN `order_expire_minutes` int(11) NOT NULL DEFAULT 30 COMMENT '订单超时时间(分钟)' AFTER `sign_type`, - ADD COLUMN `callback_retry_limit` int(11) NOT NULL DEFAULT 6 COMMENT '回调重试次数' AFTER `order_expire_minutes`, - ADD COLUMN `ip_whitelist` text COMMENT 'IP白名单' AFTER `callback_retry_limit`, - ADD COLUMN `amount_min` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '单笔最小金额' AFTER `ip_whitelist`, - ADD COLUMN `amount_max` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '单笔最大金额' AFTER `amount_min`, - ADD COLUMN `daily_limit` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '日限额' AFTER `amount_max`, - ADD COLUMN `notify_enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否开启通知:0-否,1-是' AFTER `daily_limit`, - ADD COLUMN `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注' AFTER `notify_enabled`, - ADD COLUMN `extra` json DEFAULT NULL COMMENT '扩展字段(JSON)' AFTER `remark`; - -ALTER TABLE `ma_pay_app` - ADD KEY `idx_api_type` (`api_type`), - ADD KEY `idx_status` (`status`); - --- ========================================================= --- 3. Merchant backend user table --- ========================================================= -CREATE TABLE IF NOT EXISTS `ma_mer_user` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `mer_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', - `username` varchar(50) NOT NULL DEFAULT '' COMMENT '登录账号', - `password` varchar(255) DEFAULT NULL COMMENT '登录密码hash', - `nick_name` varchar(50) NOT NULL DEFAULT '' COMMENT '昵称', - `avatar` varchar(255) NOT NULL DEFAULT '' COMMENT '头像地址', - `mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '手机号', - `email` varchar(100) NOT NULL DEFAULT '' COMMENT '邮箱', - `role_code` varchar(32) NOT NULL DEFAULT 'owner' COMMENT '角色编码', - `is_owner` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否商户主账号:0-否,1-是', - `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用', - `login_ip` varchar(45) NOT NULL DEFAULT '' COMMENT '最后登录IP', - `login_at` datetime DEFAULT NULL COMMENT '最后登录时间', - `created_at` datetime DEFAULT NULL COMMENT '创建时间', - `updated_at` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_username` (`username`), - KEY `idx_mer_id` (`mer_id`), - KEY `idx_status` (`status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户后台用户表'; - --- ========================================================= --- 4. Compatibility views for legacy code paths --- ========================================================= -DROP VIEW IF EXISTS `ma_merchant`; -CREATE VIEW `ma_merchant` AS -SELECT - `id`, - `merchant_no`, - `merchant_name`, - `merchant_short_name`, - `merchant_type`, - `group_code`, - `funds_mode`, - `legal_name`, - `contact_name`, - `contact_phone`, - `contact_email`, - `website`, - `province`, - `city`, - `address`, - `callback_domain`, - `callback_ip_whitelist`, - `risk_level`, - `settlement_mode`, - `settlement_cycle`, - `settlement_account_name`, - `settlement_account_no`, - `settlement_bank_name`, - `settlement_bank_branch`, - `single_limit`, - `daily_limit`, - `status`, - `remark`, - `extra`, - `created_at`, - `updated_at` -FROM `ma_mer`; - -DROP VIEW IF EXISTS `ma_merchant_app`; -CREATE VIEW `ma_merchant_app` AS -SELECT - `id`, - `mer_id` AS `merchant_id`, - `api_type`, - `app_code` AS `app_id`, - `app_secret`, - `app_name`, - `package_code`, - `notify_url`, - `return_url`, - `callback_mode`, - `sign_type`, - `order_expire_minutes`, - `callback_retry_limit`, - `ip_whitelist`, - `amount_min`, - `amount_max`, - `daily_limit`, - `notify_enabled`, - `status`, - `remark`, - `extra`, - `created_at`, - `updated_at` -FROM `ma_pay_app`; - -DROP VIEW IF EXISTS `ma_pay_method`; -CREATE VIEW `ma_pay_method` AS -SELECT - `id`, - `type` AS `method_code`, - `name` AS `method_name`, - `icon`, - `sort`, - `status`, - `created_at`, - `updated_at` -FROM `ma_pay_type`; - -SET FOREIGN_KEY_CHECKS = 1; diff --git a/database/dev_seed.sql b/database/dev_seed.sql deleted file mode 100644 index 46184e1..0000000 --- a/database/dev_seed.sql +++ /dev/null @@ -1,194 +0,0 @@ --- 开发环境初始化数据(可重复执行) -SET NAMES utf8mb4; - --- 1) 管理员(若已有则跳过) -INSERT INTO `ma_admin` (`user_name`, `password`, `nick_name`, `status`, `created_at`) -VALUES ('admin', NULL, '超级管理员', 1, NOW()) -ON DUPLICATE KEY UPDATE - `nick_name` = VALUES(`nick_name`), - `status` = VALUES(`status`); - --- 2) 商户 -INSERT INTO `ma_merchant` (`merchant_no`, `merchant_name`, `funds_mode`, `status`, `created_at`, `updated_at`) -VALUES ('M001', '测试商户', 'direct', 1, NOW(), NOW()) -ON DUPLICATE KEY UPDATE - `merchant_name` = VALUES(`merchant_name`), - `funds_mode` = VALUES(`funds_mode`), - `status` = VALUES(`status`), - `updated_at` = NOW(); - --- 3) 商户应用(pid=app_id 约定:这里 app_id 使用纯数字字符串,方便易支付测试) -INSERT INTO `ma_merchant_app` (`merchant_id`, `api_type`, `app_id`, `app_secret`, `app_name`, `status`, `created_at`, `updated_at`) -SELECT m.id, 'epay', '1001', 'dev_secret_1001', '测试应用-易支付', 1, NOW(), NOW() -FROM `ma_merchant` m -WHERE m.merchant_no = 'M001' -ON DUPLICATE KEY UPDATE - `app_secret` = VALUES(`app_secret`), - `app_name` = VALUES(`app_name`), - `status` = VALUES(`status`), - `updated_at` = NOW(); - --- 4) 支付方式 -INSERT INTO `ma_pay_method` (`method_code`, `method_name`, `icon`, `sort`, `status`, `created_at`, `updated_at`) VALUES -('alipay', '支付宝', '', 1, 1, NOW(), NOW()), -('wechat', '微信支付', '', 2, 1, NOW(), NOW()), -('unionpay','云闪付', '', 3, 1, NOW(), NOW()) -ON DUPLICATE KEY UPDATE - `method_name` = VALUES(`method_name`), - `icon` = VALUES(`icon`), - `sort` = VALUES(`sort`), - `status` = VALUES(`status`), - `updated_at` = NOW(); - --- 5) 插件注册表(按项目约定:类名短写,如 AlipayPayment) -INSERT INTO `ma_pay_plugin` (`code`, `name`, `class_name`, `status`, `created_at`, `updated_at`) -VALUES - ('lakala', '拉卡拉(示例)', 'LakalaPayment', 1, NOW(), NOW()), - ('alipay', '支付宝直连', 'AlipayPayment', 1, NOW(), NOW()) -ON DUPLICATE KEY UPDATE - `name` = VALUES(`name`), - `class_name` = VALUES(`class_name`), - `status` = VALUES(`status`), - `updated_at` = NOW(); - --- 6) 系统配置(开发环境默认配置,可根据需要修改) -INSERT INTO `ma_system_config` (`config_key`, `config_value`, `created_at`, `updated_at`) VALUES -('site_name', 'Mpay', NOW(), NOW()), -('site_description', '码支付', NOW(), NOW()), -('site_logo', '', NOW(), NOW()), -('icp_number', '', NOW(), NOW()), -('site_status', '1', NOW(), NOW()), -('page_size', '10', NOW(), NOW()), -('enable_permission', '1', NOW(), NOW()), -('session_timeout', '15', NOW(), NOW()), -('password_min_length', '8', NOW(), NOW()), -('require_strong_password','1', NOW(), NOW()), -('max_login_attempts', '5', NOW(), NOW()), -('lockout_duration', '30', NOW(), NOW()), -('smtp_host', 'smtp.example.com', NOW(), NOW()), -('smtp_port', '465', NOW(), NOW()), -('smtp_ssl', '1', NOW(), NOW()), -('smtp_username', 'noreply@example.com', NOW(), NOW()), -('smtp_password', 'dev_smtp_password', NOW(), NOW()), -('from_email', 'noreply@example.com', NOW(), NOW()), -('from_name', 'Mpay', NOW(), NOW()) -ON DUPLICATE KEY UPDATE - `config_value` = VALUES(`config_value`), - `updated_at` = NOW(); - --- 7) 支付通道(为测试商户 M001 / 应用 1001 初始化拉卡拉通道) -INSERT INTO `ma_pay_channel` ( - `mer_id`, - `app_id`, - `chan_code`, - `chan_name`, - `plugin_code`, - `pay_type_id`, - `config`, - `split_ratio`, - `chan_cost`, - `chan_mode`, - `daily_limit`, - `daily_cnt`, - `min_amount`, - `max_amount`, - `status`, - `sort`, - `created_at`, - `updated_at` -) -SELECT - m.id AS mer_id, - app.id AS app_id, - 'lakala_alipay' AS chan_code, - '拉卡拉-支付宝' AS chan_name, - 'lakala' AS plugin_code, - pm.id AS pay_type_id, - JSON_OBJECT('notify_url', 'https://example.com/notify') AS config, - 100.00 AS split_ratio, - 0.00 AS chan_cost, - 0 AS chan_mode, - 0.00 AS daily_limit, - 0 AS daily_cnt, - 0.01 AS min_amount, - NULL AS max_amount, - 1 AS status, - 10 AS sort, - NOW() AS created_at, - NOW() AS updated_at -FROM `ma_merchant` m -JOIN `ma_merchant_app` app ON app.merchant_id = m.id AND app.app_id = '1001' -JOIN `ma_pay_method` pm ON pm.method_code = 'alipay' -ON DUPLICATE KEY UPDATE - `chan_name` = VALUES(`chan_name`), - `plugin_code` = VALUES(`plugin_code`), - `pay_type_id` = VALUES(`pay_type_id`), - `config` = VALUES(`config`), - `split_ratio` = VALUES(`split_ratio`), - `chan_cost` = VALUES(`chan_cost`), - `chan_mode` = VALUES(`chan_mode`), - `daily_limit` = VALUES(`daily_limit`), - `daily_cnt` = VALUES(`daily_cnt`), - `min_amount` = VALUES(`min_amount`), - `max_amount` = VALUES(`max_amount`), - `status` = VALUES(`status`), - `sort` = VALUES(`sort`), - `updated_at` = NOW(); - -INSERT INTO `ma_pay_channel` ( - `mer_id`, - `app_id`, - `chan_code`, - `chan_name`, - `plugin_code`, - `pay_type_id`, - `config`, - `split_ratio`, - `chan_cost`, - `chan_mode`, - `daily_limit`, - `daily_cnt`, - `min_amount`, - `max_amount`, - `status`, - `sort`, - `created_at`, - `updated_at` -) -SELECT - m.id AS mer_id, - app.id AS app_id, - 'lakala_wechat' AS chan_code, - '拉卡拉-微信支付' AS chan_name, - 'lakala' AS plugin_code, - pm.id AS pay_type_id, - JSON_OBJECT('notify_url', 'https://example.com/notify') AS config, - 100.00 AS split_ratio, - 0.00 AS chan_cost, - 0 AS chan_mode, - 0.00 AS daily_limit, - 0 AS daily_cnt, - 0.01 AS min_amount, - NULL AS max_amount, - 1 AS status, - 20 AS sort, - NOW() AS created_at, - NOW() AS updated_at -FROM `ma_merchant` m -JOIN `ma_merchant_app` app ON app.merchant_id = m.id AND app.app_id = '1001' -JOIN `ma_pay_method` pm ON pm.method_code = 'wechat' -ON DUPLICATE KEY UPDATE - `chan_name` = VALUES(`chan_name`), - `plugin_code` = VALUES(`plugin_code`), - `pay_type_id` = VALUES(`pay_type_id`), - `config` = VALUES(`config`), - `split_ratio` = VALUES(`split_ratio`), - `chan_cost` = VALUES(`chan_cost`), - `chan_mode` = VALUES(`chan_mode`), - `daily_limit` = VALUES(`daily_limit`), - `daily_cnt` = VALUES(`daily_cnt`), - `min_amount` = VALUES(`min_amount`), - `max_amount` = VALUES(`max_amount`), - `status` = VALUES(`status`), - `sort` = VALUES(`sort`), - `updated_at` = NOW(); diff --git a/database/mvp_payment_tables.sql b/database/mvp_payment_tables.sql deleted file mode 100644 index a96cc2a..0000000 --- a/database/mvp_payment_tables.sql +++ /dev/null @@ -1,251 +0,0 @@ --- ============================================ --- 支付系统核心表结构(优化版) --- ============================================ - -SET NAMES utf8mb4; -SET FOREIGN_KEY_CHECKS = 0; - --- ======================= --- 1. 商户表 --- ======================= -DROP TABLE IF EXISTS `ma_merchant`; -CREATE TABLE `ma_merchant` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `merchant_no` varchar(32) NOT NULL DEFAULT '' COMMENT '商户号(唯一,对外标识)', - `merchant_name` varchar(100) NOT NULL DEFAULT '' COMMENT '商户名称', - `funds_mode` varchar(20) NOT NULL DEFAULT 'direct' COMMENT '资金模式:direct-直连, wallet-归集, hybrid-混合', - `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用', - `created_at` datetime DEFAULT NULL COMMENT '创建时间', - `updated_at` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_merchant_no` (`merchant_no`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户表'; - --- ======================= --- 2. 商户应用表 --- ======================= -DROP TABLE IF EXISTS `ma_merchant_app`; -CREATE TABLE `ma_merchant_app` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', - `api_type` varchar(32) NOT NULL DEFAULT 'default' COMMENT '接口类型:openapi, epay, custom 等', - `app_id` varchar(64) NOT NULL DEFAULT '' COMMENT '应用ID', - `app_secret` varchar(128) NOT NULL DEFAULT '' COMMENT '应用密钥', - `app_name` varchar(100) NOT NULL DEFAULT '' COMMENT '应用名称', - `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用', - `created_at` datetime DEFAULT NULL COMMENT '创建时间', - `updated_at` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_app_id` (`app_id`), - KEY `idx_merchant_id` (`merchant_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户应用表'; - --- ======================= --- 3. 支付方式字典表 --- ======================= -DROP TABLE IF EXISTS `ma_pay_method`; -CREATE TABLE `ma_pay_method` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `method_code` varchar(32) NOT NULL DEFAULT '' COMMENT '支付方式编码,如 alipay,wechat', - `method_name` varchar(50) NOT NULL DEFAULT '' COMMENT '支付方式名称', - `icon` varchar(255) NOT NULL DEFAULT '' COMMENT '图标', - `sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序', - `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用', - `created_at` datetime DEFAULT NULL COMMENT '创建时间', - `updated_at` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_method_code` (`method_code`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付方式字典表'; - --- ======================= --- 4. 支付插件注册表 --- ======================= -DROP TABLE IF EXISTS `ma_pay_plugin`; -CREATE TABLE `ma_pay_plugin` ( - `code` varchar(32) NOT NULL DEFAULT '' COMMENT '插件编码(主键)', - `name` varchar(50) NOT NULL DEFAULT '' COMMENT '插件名称', - `class_name` varchar(255) NOT NULL DEFAULT '' COMMENT '插件类名(短类名)', - `config_schema` json DEFAULT NULL COMMENT '插件配置schema(JSON)', - `pay_types` json DEFAULT NULL COMMENT '插件支持支付类型(JSON)', - `transfer_types` json DEFAULT NULL COMMENT '插件支持转账类型(JSON)', - `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用', - `created_at` datetime DEFAULT NULL COMMENT '创建时间', - `updated_at` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`code`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付插件注册表'; - --- ======================= --- 5. 支付通道表 --- ======================= -DROP TABLE IF EXISTS `ma_pay_channel`; -CREATE TABLE `ma_pay_channel` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID(冗余,方便统计)', - `merchant_app_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户应用ID(关联 ma_merchant_app.id)', - `chan_code` varchar(32) NOT NULL DEFAULT '' COMMENT '通道编码(唯一)', - `chan_name` varchar(100) NOT NULL DEFAULT '' COMMENT '通道显示名称', - `plugin_code` varchar(32) NOT NULL DEFAULT '' COMMENT '支付插件编码', - `method_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付方式ID(关联 ma_pay_method.id)', - `config_json` json DEFAULT NULL COMMENT '通道插件配置参数(JSON,对应插件配置,包括 enabled_products 等)', - `split_ratio` decimal(5,2) NOT NULL DEFAULT 100.00 COMMENT '分成比例(%)', - `chan_cost` decimal(5,2) NOT NULL DEFAULT 0.00 COMMENT '通道成本(%)', - `chan_mode` varchar(50) NOT NULL DEFAULT 'wallet' COMMENT '通道模式:wallet-入余额, direct-直连到商户', - `daily_limit` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '单日限额(元,0表示不限制)', - `daily_cnt` int(11) NOT NULL DEFAULT 0 COMMENT '单日限笔(0表示不限制)', - `min_amount` decimal(12,2) DEFAULT NULL COMMENT '单笔最小金额(元,NULL表示不限制)', - `max_amount` decimal(12,2) DEFAULT NULL COMMENT '单笔最大金额(元,NULL表示不限制)', - `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用', - `sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序,越小优先级越高', - `created_at` datetime DEFAULT NULL COMMENT '创建时间', - `updated_at` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_chan_code` (`chan_code`), - KEY `idx_mch_app_method` (`merchant_id`,`merchant_app_id`,`method_id`,`status`,`sort`), - KEY `idx_plugin_method` (`plugin_code`,`method_id`), - KEY `idx_status` (`status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付通道表'; - --- ======================= --- 6. 支付订单表 --- ======================= -DROP TABLE IF EXISTS `ma_pay_order`; -CREATE TABLE `ma_pay_order` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `order_id` varchar(32) NOT NULL DEFAULT '' COMMENT '支付订单号(系统生成,唯一)', - `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', - `merchant_app_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户应用ID', - `mch_order_no` varchar(64) NOT NULL DEFAULT '' COMMENT '商户订单号(幂等)', - `method_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付方式ID(关联 ma_pay_method.id)', - `channel_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付通道ID(关联 ma_pay_channel.id)', - `amount` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '订单金额(元)', - `real_amount` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '实际支付金额(元,扣除手续费后)', - `fee` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '手续费(元,可选,用于对账)', - `currency` varchar(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,如 CNY', - `subject` varchar(255) NOT NULL DEFAULT '' COMMENT '订单标题', - `body` varchar(500) NOT NULL DEFAULT '' COMMENT '订单描述', - `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '订单状态:0-PENDING,1-SUCCESS,2-FAIL,3-CLOSED', - `chan_order_no` varchar(128) NOT NULL DEFAULT '' COMMENT '渠道订单号(渠道返回)', - `chan_trade_no` varchar(128) NOT NULL DEFAULT '' COMMENT '渠道交易号(部分渠道有)', - `pay_at` datetime DEFAULT NULL COMMENT '支付时间', - `expire_at` datetime DEFAULT NULL COMMENT '订单过期时间', - `client_ip` varchar(50) NOT NULL DEFAULT '' COMMENT '客户端IP', - `notify_stat` tinyint(1) NOT NULL DEFAULT 0 COMMENT '商户通知状态:0-未通知,1-已通知成功', - `notify_cnt` int(11) NOT NULL DEFAULT 0 COMMENT '通知次数', - `extra` json DEFAULT NULL COMMENT '扩展字段(JSON,存储支付参数、退款信息等)', - `created_at` datetime DEFAULT NULL COMMENT '创建时间', - `updated_at` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_order_id` (`order_id`), - UNIQUE KEY `uk_mch_order` (`merchant_id`,`merchant_app_id`,`mch_order_no`), - KEY `idx_mch_app_created` (`merchant_id`,`merchant_app_id`,`created_at`), - KEY `idx_method_id` (`method_id`), - KEY `idx_channel_id` (`channel_id`), - KEY `idx_status_created` (`status`,`created_at`), - KEY `idx_pay_at` (`pay_at`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表'; - --- ======================= --- 7. 支付回调日志表 --- ======================= -DROP TABLE IF EXISTS `ma_pay_callback_log`; -CREATE TABLE `ma_pay_callback_log` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `order_id` varchar(32) NOT NULL DEFAULT '' COMMENT '支付订单号(系统订单号)', - `channel_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '通道ID(关联 ma_pay_channel.id)', - `callback_type` varchar(20) NOT NULL DEFAULT '' COMMENT '回调类型:notify-异步通知, return-同步返回', - `request_data` text COMMENT '请求原始数据(完整回调参数)', - `verify_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '验签状态:0-失败,1-成功', - `process_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '处理状态:0-未处理,1-已处理', - `process_result` text COMMENT '处理结果(JSON或文本)', - `created_at` datetime DEFAULT NULL COMMENT '创建时间', - PRIMARY KEY (`id`), - KEY `idx_order_created` (`order_id`,`created_at`), - KEY `idx_channel_created` (`channel_id`,`created_at`), - KEY `idx_callback_type` (`callback_type`), - KEY `idx_verify_status` (`verify_status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付回调日志表'; - --- ======================= --- 8. 商户通知任务表 --- ======================= -DROP TABLE IF EXISTS `ma_notify_task`; -CREATE TABLE `ma_notify_task` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `order_id` varchar(32) NOT NULL DEFAULT '' COMMENT '支付订单号(系统订单号)', - `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', - `merchant_app_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户应用ID', - `notify_url` varchar(255) NOT NULL DEFAULT '' COMMENT '通知地址', - `notify_data` text COMMENT '通知数据(JSON格式)', - `status` varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING-待通知,SUCCESS-成功,FAIL-失败', - `retry_cnt` int(11) NOT NULL DEFAULT 0 COMMENT '重试次数', - `next_retry_at` datetime DEFAULT NULL COMMENT '下次重试时间', - `last_notify_at` datetime DEFAULT NULL COMMENT '最后通知时间', - `last_response` text COMMENT '最后响应内容', - `created_at` datetime DEFAULT NULL COMMENT '创建时间', - `updated_at` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_order_id` (`order_id`), - KEY `idx_status_retry` (`status`,`next_retry_at`), - KEY `idx_mch_app` (`merchant_id`,`merchant_app_id`), - KEY `idx_created_at` (`created_at`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户通知任务表'; - --- ======================= --- 9. 系统配置表 --- ======================= -DROP TABLE IF EXISTS `ma_system_config`; -CREATE TABLE IF NOT EXISTS `ma_system_config` ( - `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `config_key` varchar(100) NOT NULL DEFAULT '' COMMENT '配置项键名(唯一标识,直接使用字段名)', - `config_value` text COMMENT '配置项值(支持字符串、数字、JSON等)', - `created_at` datetime DEFAULT NULL COMMENT '创建时间', - `updated_at` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_config_key` (`config_key`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表'; - --- ======================= --- 10. 初始化基础数据 --- ======================= - --- 初始化支付方式字典 -INSERT INTO `ma_pay_method` (`method_code`, `method_name`, `icon`, `sort`, `status`) VALUES -('alipay', '支付宝', '', 1, 1), -('wechat', '微信支付', '', 2, 1), -('unionpay','云闪付', '', 3, 1) -ON DUPLICATE KEY UPDATE - `method_name` = VALUES(`method_name`), - `icon` = VALUES(`icon`), - `sort` = VALUES(`sort`), - `status` = VALUES(`status`); - --- ======================= --- 11. 管理员用户表(ma_admin) --- ======================= - -DROP TABLE IF EXISTS `ma_admin`; -CREATE TABLE `ma_admin` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `user_name` varchar(50) NOT NULL DEFAULT '' COMMENT '用户名(登录账号,唯一)', - `password` varchar(255) DEFAULT NULL COMMENT '登录密码hash(NULL 或空表示使用默认开发密码)', - `nick_name` varchar(50) NOT NULL DEFAULT '' COMMENT '昵称', - `avatar` varchar(255) NOT NULL DEFAULT '' COMMENT '头像地址', - `mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '手机号', - `email` varchar(100) NOT NULL DEFAULT '' COMMENT '邮箱', - `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用', - `login_ip` varchar(45) NOT NULL DEFAULT '' COMMENT '最后登录IP', - `login_at` datetime DEFAULT NULL COMMENT '最后登录时间', - `created_at` datetime DEFAULT NULL COMMENT '创建时间', - `updated_at` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_user_name` (`user_name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员用户表'; - --- 初始化一个超级管理员账号(开发环境默认密码 123456,对应 AuthService::validatePassword 逻辑) -INSERT INTO `ma_admin` (`user_name`, `password`, `nick_name`, `status`, `created_at`) -VALUES ('admin', NULL, '超级管理员', 1, NOW()) -ON DUPLICATE KEY UPDATE - `nick_name` = VALUES(`nick_name`), - `status` = VALUES(`status`); - -SET FOREIGN_KEY_CHECKS = 1; \ No newline at end of file diff --git a/database/patch_callback_inbox.sql b/database/patch_callback_inbox.sql deleted file mode 100644 index 0ab9bb5..0000000 --- a/database/patch_callback_inbox.sql +++ /dev/null @@ -1,20 +0,0 @@ -SET NAMES utf8mb4; - -CREATE TABLE IF NOT EXISTS `ma_callback_inbox` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `event_key` char(40) NOT NULL DEFAULT '' COMMENT '幂等事件键(SHA1)', - `plugin_code` varchar(32) NOT NULL DEFAULT '' COMMENT '插件编码', - `order_id` varchar(32) NOT NULL DEFAULT '' COMMENT '系统订单号', - `chan_trade_no` varchar(128) NOT NULL DEFAULT '' COMMENT '渠道交易号', - `payload` json DEFAULT NULL COMMENT '回调原始数据', - `process_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '处理状态:0-待处理 1-已处理', - `processed_at` datetime DEFAULT NULL COMMENT '处理完成时间', - `created_at` datetime DEFAULT NULL COMMENT '创建时间', - `updated_at` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_event_key` (`event_key`), - KEY `idx_order_id` (`order_id`), - KEY `idx_plugin_code` (`plugin_code`), - KEY `idx_created_at` (`created_at`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付回调幂等收件箱'; - diff --git a/doc/epay.md b/doc/epay.md deleted file mode 100644 index 8ece215..0000000 --- a/doc/epay.md +++ /dev/null @@ -1,216 +0,0 @@ -协议规则 -请求数据格式:application/x-www-form-urlencoded - -返回数据格式:JSON - -签名算法:MD5 - -字符编码:UTF-8 - -页面跳转支付 -此接口可用于用户前台直接发起支付,使用form表单跳转或拼接成url跳转。 - -URL地址:http://192.168.31.200:4000/submit.php - -请求方式:POST 或 GET(推荐POST,不容易被劫持或屏蔽) - -请求参数说明: - -字段名 变量名 必填 类型 示例值 描述 -商户ID pid 是 Int 1001 -支付方式 type 否 String alipay 支付方式列表 -商户订单号 out_trade_no 是 String 20160806151343349 -异步通知地址 notify_url 是 String http://www.pay.com/notify_url.php 服务器异步通知地址 -跳转通知地址 return_url 是 String http://www.pay.com/return_url.php 页面跳转通知地址 -商品名称 name 是 String VIP会员 如超过127个字节会自动截取 -商品金额 money 是 String 1.00 单位:元,最大2位小数 -业务扩展参数 param 否 String 没有请留空 支付后原样返回 -签名字符串 sign 是 String 202cb962ac59075b964b07152d234b70 签名算法点此查看 -签名类型 sign_type 是 String MD5 默认为MD5 -支付方式(type)不传会跳转到收银台支付 - -API接口支付 -此接口可用于服务器后端发起支付请求,会返回支付二维码链接或支付跳转url。 - -URL地址:http://192.168.31.200:4000/mapi.php - -请求方式:POST - -请求参数说明: - -字段名 变量名 必填 类型 示例值 描述 -商户ID pid 是 Int 1001 -支付方式 type 是 String alipay 支付方式列表 -商户订单号 out_trade_no 是 String 20160806151343349 -异步通知地址 notify_url 是 String http://www.pay.com/notify_url.php 服务器异步通知地址 -跳转通知地址 return_url 否 String http://www.pay.com/return_url.php 页面跳转通知地址 -商品名称 name 是 String VIP会员 如超过127个字节会自动截取 -商品金额 money 是 String 1.00 单位:元,最大2位小数 -用户IP地址 clientip 是 String 192.168.1.100 用户发起支付的IP地址 -设备类型 device 否 String pc 根据用户浏览器的UA判断, -传入用户所使用的浏览器 -或设备类型,默认为pc -设备类型列表 -业务扩展参数 param 否 String 没有请留空 支付后原样返回 -签名字符串 sign 是 String 202cb962ac59075b964b07152d234b70 签名算法点此查看 -签名类型 sign_type 是 String MD5 默认为MD5 -返回结果(json): - -字段名 变量名 类型 示例值 描述 -返回状态码 code Int 1 1为成功,其它值为失败 -返回信息 msg String 失败时返回原因 -订单号 trade_no String 20160806151343349 支付订单号 -支付跳转url payurl String http://192.168.31.200:4000/pay/wxpay/202010903/ 如果返回该字段,则直接跳转到该url支付 -二维码链接 qrcode String weixin://wxpay/bizpayurl?pr=04IPMKM 如果返回该字段,则根据该url生成二维码 -小程序跳转url urlscheme String weixin://dl/business/?ticket=xxx 如果返回该字段,则使用js跳转该url,可发起微信小程序支付 -注:payurl、qrcode、urlscheme 三个参数只会返回其中一个 - -支付结果通知 -通知类型:服务器异步通知(notify_url)、页面跳转通知(return_url) - -请求方式:GET - -请求参数说明: - -字段名 变量名 必填 类型 示例值 描述 -商户ID pid 是 Int 1001 -易支付订单号 trade_no 是 String 20160806151343349021 聚合支付平台订单号 -商户订单号 out_trade_no 是 String 20160806151343349 商户系统内部的订单号 -支付方式 type 是 String alipay 支付方式列表 -商品名称 name 是 String VIP会员 -商品金额 money 是 String 1.00 -支付状态 trade_status 是 String TRADE_SUCCESS 只有TRADE_SUCCESS是成功 -业务扩展参数 param 否 String -签名字符串 sign 是 String 202cb962ac59075b964b07152d234b70 签名算法点此查看 -签名类型 sign_type 是 String MD5 默认为MD5 -收到异步通知后,需返回success以表示服务器接收到了订单通知 - -MD5签名算法 -1、将发送或接收到的所有参数按照参数名ASCII码从小到大排序(a-z),sign、sign_type、和空值不参与签名! - -2、将排序后的参数拼接成URL键值对的格式,例如 a=b&c=d&e=f,参数值不要进行url编码。 - -3、再将拼接好的字符串与商户密钥KEY进行MD5加密得出sign签名参数,sign = md5 ( a=b&c=d&e=f + KEY ) (注意:+ 为各语言的拼接符,不是字符!),md5结果为小写。 - -4、具体签名与发起支付的示例代码可下载SDK查看。 - -支付方式列表 -调用值 描述 -alipay 支付宝 -wxpay 微信支付 -qqpay QQ钱包 -设备类型列表 -调用值 描述 -pc 电脑浏览器 -mobile 手机浏览器 -qq 手机QQ内浏览器 -wechat 微信内浏览器 -alipay 支付宝客户端 -jump 仅返回支付跳转url -[API]查询商户信息 -URL地址:http://192.168.31.200:4000/api.php?act=query&pid={商户ID}&key={商户密钥} - -请求参数说明: - -字段名 变量名 必填 类型 示例值 描述 -操作类型 act 是 String query 此API固定值 -商户ID pid 是 Int 1001 -商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i -返回结果: - -字段名 变量名 类型 示例值 描述 -返回状态码 code Int 1 1为成功,其它值为失败 -商户ID pid Int 1001 商户ID -商户密钥 key String(32) 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i 商户密钥 -商户状态 active Int 1 1为正常,0为封禁 -商户余额 money String 0.00 商户所拥有的余额 -结算方式 type Int 1 1:支付宝,2:微信,3:QQ,4:银行卡 -结算账号 account String admin@pay.com 结算的支付宝账号 -结算姓名 username String 张三 结算的支付宝姓名 -订单总数 orders Int 30 订单总数统计 -今日订单 order_today Int 15 今日订单数量 -昨日订单 order_lastday Int 15 昨日订单数量 -[API]查询结算记录 -URL地址:http://192.168.31.200:4000/api.php?act=settle&pid={商户ID}&key={商户密钥} - -请求参数说明: - -字段名 变量名 必填 类型 示例值 描述 -操作类型 act 是 String settle 此API固定值 -商户ID pid 是 Int 1001 -商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i -返回结果: - -字段名 变量名 类型 示例值 描述 -返回状态码 code Int 1 1为成功,其它值为失败 -返回信息 msg String 查询结算记录成功! -结算记录 data Array 结算记录列表 -[API]查询单个订单 -URL地址:http://192.168.31.200:4000/api.php?act=order&pid={商户ID}&key={商户密钥}&out_trade_no={商户订单号} - -请求参数说明: - -字段名 变量名 必填 类型 示例值 描述 -操作类型 act 是 String order 此API固定值 -商户ID pid 是 Int 1001 -商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i -系统订单号 trade_no 选择 String 20160806151343312 -商户订单号 out_trade_no 选择 String 20160806151343349 -提示:系统订单号 和 商户订单号 二选一传入即可,如果都传入以系统订单号为准! - -返回结果: - -字段名 变量名 类型 示例值 描述 -返回状态码 code Int 1 1为成功,其它值为失败 -返回信息 msg String 查询订单号成功! -易支付订单号 trade_no String 2016080622555342651 聚合支付平台订单号 -商户订单号 out_trade_no String 20160806151343349 商户系统内部的订单号 -第三方订单号 api_trade_no String 20160806151343349 支付宝微信等接口方订单号 -支付方式 type String alipay 支付方式列表 -商户ID pid Int 1001 发起支付的商户ID -创建订单时间 addtime String 2016-08-06 22:55:52 -完成交易时间 endtime String 2016-08-06 22:55:52 -商品名称 name String VIP会员 -商品金额 money String 1.00 -支付状态 status Int 0 1为支付成功,0为未支付 -业务扩展参数 param String 默认留空 -支付者账号 buyer String 默认留空 -[API]批量查询订单 -URL地址:http://192.168.31.200:4000/api.php?act=orders&pid={商户ID}&key={商户密钥} - -请求参数说明: - -字段名 变量名 必填 类型 示例值 描述 -操作类型 act 是 String orders 此API固定值 -商户ID pid 是 Int 1001 -商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i -查询订单数量 limit 否 Int 20 返回的订单数量,最大50 -页码 page 否 Int 1 当前查询的页码 -返回结果: - -字段名 变量名 类型 示例值 描述 -返回状态码 code Int 1 1为成功,其它值为失败 -返回信息 msg String 查询结算记录成功! -订单列表 data Array 订单列表 -[API]提交订单退款 -需要先在商户后台开启订单退款API接口开关,才能调用该接口发起订单退款 - -URL地址:http://192.168.31.200:4000/api.php?act=refund - -请求方式:POST - -请求参数说明: - -字段名 变量名 必填 类型 示例值 描述 -商户ID pid 是 Int 1001 -商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i -易支付订单号 trade_no 特殊可选 String 20160806151343349021 易支付订单号 -商户订单号 out_trade_no 特殊可选 String 20160806151343349 订单支付时传入的商户订单号,商家自定义且保证商家系统中唯一 -退款金额 money 是 String 1.50 少数通道需要与原订单金额一致 -注:trade_no、out_trade_no 不能同时为空,如果都传了以trade_no为准 - -返回结果: - -字段名 变量名 类型 示例值 描述 -返回状态码 code Int 0 0为成功,其它值为失败 -返回信息 msg String 退款成功 \ No newline at end of file diff --git a/doc/event.md b/doc/event.md deleted file mode 100644 index 897d6d2..0000000 --- a/doc/event.md +++ /dev/null @@ -1,112 +0,0 @@ -event事件处理 -webman/event 提供一种精巧的事件机制,可实现在不侵入代码的情况下执行一些业务逻辑,实现业务模块之间的解耦。典型的场景如一个新用户注册成功时,只要发布一个自定义事件如user.register,各个模块遍能收到该事件执行相应的业务逻辑。 - -安装 -composer require webman/event - -订阅事件 -订阅事件统一通过文件config/event.php来配置 - - [ - [app\event\User::class, 'register'], - // ...其它事件处理函数... - ], - 'user.logout' => [ - [app\event\User::class, 'logout'], - // ...其它事件处理函数... - ] -]; -说明: - -user.register user.logout 等是事件名称,字符串类型,建议小写单词并以点(.)分割 -一个事件可以对应多个事件处理函数,调用顺序为配置的顺序 -事件处理函数 -事件处理函数可以是任意的类方法、函数、闭包函数等。 -例如创建事件处理类 app/event/User.php (目录不存在请自行创建) - - 'webman', - 'age' => 2 - ]; - Event::dispatch('user.register', $user); - } -} -发布事件有两个函数,Event::dispatch($event_name, $data); 和 Event::emit($event_name, $data); 二者参数一样。 -区别是emit内部会自动捕获异常,也就是说如果一个事件有多个处理函数,某个处理函数发生异常不会影响其它处理函数的执行。 -而dispatch则内部不会自动捕获异常,当前事件的任何一个处理函数发生异常,则停止执行下一个处理函数并直接向上抛出异常。 - -提示 -参数$data可以是任意的数据,例如数组、类实例、字符串等 - -通配符事件监听 -通配符注册监听允许您在同一个监听器上处理多个事件,例如config/event.php里配置 - - [ - [app\event\User::class, 'deal'] - ], -]; -我们可以通过事件处理函数第二个参数$event_data获得具体的事件名 - - [ - function($user){ - var_dump($user); - } - ] -]; -查看事件及监听器 -使用命令 php webman event:list 查看项目配置的所有事件及监听器 - -支持范围 -除了主项目基础插件和应用插件同样支持event.php配置。 -基础插件配置文件 config/plugin/插件厂商/插件名/event.php -应用插件配置文件 plugin/插件名/config/event.php - -注意事项 -event事件处理并不是异步的,event不适合处理慢业务,慢业务应该用消息队列处理 \ No newline at end of file diff --git a/doc/exception.md b/doc/exception.md deleted file mode 100644 index c295218..0000000 --- a/doc/exception.md +++ /dev/null @@ -1,114 +0,0 @@ -异常处理 -配置 -config/exception.php - -return [ - // 这里配置异常处理类 - '' => support\exception\Handler::class, -]; -多应用模式时,你可以为每个应用单独配置异常处理类,参见多应用 - -默认异常处理类 -webman中异常默认由 support\exception\Handler 类来处理。可修改配置文件config/exception.php来更改默认异常处理类。异常处理类必须实现Webman\Exception\ExceptionHandlerInterface 接口。 - -interface ExceptionHandlerInterface -{ - /** - * 记录日志 - * @param Throwable $e - * @return mixed - */ - public function report(Throwable $e); - - /** - * 渲染返回 - * @param Request $request - * @param Throwable $e - * @return Response - */ - public function render(Request $request, Throwable $e) : Response; -} -渲染响应 -异常处理类中的render方法是用来渲染响应的。 - -如果配置文件config/app.php中debug值为true(以下简称app.debug=true),将返回详细的异常信息,否则将返回简略的异常信息。 - -如果请求期待是json返回,则返回的异常信息将以json格式返回,类似 - -{ - "code": "500", - "msg": "异常信息" -} -如果app.debug=true,json数据里会额外增加一个trace字段返回详细的调用栈。 - -你可以编写自己的异常处理类来更改默认异常处理逻辑。 - -业务异常 BusinessException -有时候我们想在某个嵌套函数里终止请求并返回一个错误信息给客户端,这时可以通过抛出BusinessException来做到这点。 -例如: - -checkInput($request->post()); - return response('hello index'); - } - - protected function checkInput($input) - { - if (!isset($input['token'])) { - throw new BusinessException('参数错误', 3000); - } - } -} -以上示例会返回一个 - -{"code": 3000, "msg": "参数错误"} -注意 -业务异常BusinessException不需要业务try捕获,框架会自动捕获并根据请求类型返回合适的输出。 - -自定义业务异常 -如果以上响应不符合你的需求,例如想把msg要改为message,可以自定义一个MyBusinessException - -新建 app/exception/MyBusinessException.php 内容如下 - -expectsJson()) { - return json(['code' => $this->getCode() ?: 500, 'message' => $this->getMessage()]); - } - // 非json请求则返回一个页面 - return new Response(200, [], $this->getMessage()); - } -} -这样当业务调用 - -use app\exception\MyBusinessException; - -throw new MyBusinessException('参数错误', 3000); -json请求将收到一个类似如下的json返回 - -{"code": 3000, "message": "参数错误"} -提示 -因为BusinessException异常属于业务异常(例如用户输入参数错误),它是可预知的,所以框架并不会认为它是致命错误,并不会记录日志。 - -总结 -在任何想中断当前请求并返回信息给客户端的时候可以考虑使用BusinessException异常。 \ No newline at end of file diff --git a/doc/project_overview.md b/doc/project_overview.md deleted file mode 100644 index dbd8b86..0000000 --- a/doc/project_overview.md +++ /dev/null @@ -1,414 +0,0 @@ -# MPay V2 Project Overview - -更新日期:2026-03-13 - -## 1. 项目定位 - -这是一个基于 Webman 的多商户支付中台项目,当前主要目标是: - -- 提供后台管理能力,维护商户、应用、支付方式、支付插件、支付通道、订单与系统配置 -- 为商户应用提供统一支付能力 -- 当前已优先兼容 `epay` 协议,后续可继续扩展更多外部支付协议 -- 通过“支付插件 + 通道配置”的方式对接第三方渠道 - -结合当前代码与数据库,项目已经具备“多商户 -> 多应用 -> 多通道 -> 多插件”的基础骨架。 - -## 2. 技术栈与运行环境 - -### 后端技术栈 - -- PHP `>= 8.1` -- Webman `^2.1` -- webman/database -- webman/redis -- webman/cache -- webman/console -- webman/captcha -- webman/event -- webman/redis-queue -- firebase/php-jwt -- yansongda/pay `~3.7.0` - -### 当前环境配置要点 - -- HTTP 服务监听:`0.0.0.0:8787` -- 数据库:MySQL -- 缓存与队列:Redis -- 管理后台认证:JWT -- 当前 `.env` 已配置远程 MySQL / Redis 地址,开发前需要确认本机网络可达 - -## 3. 当前环境可调用的 MCP 能力 - -本次会话中,已确认可以直接用于本项目的 MCP / 环境能力如下: - -### MySQL MCP - -- 可直接执行 SQL -- 可读取当前开发库表结构与数据 -- 已确认能访问 `mpay_admin` 相关表,例如: - - `ma_merchant` - - `ma_merchant_app` - - `ma_pay_channel` - - `ma_pay_order` - - `ma_notify_task` - - `ma_callback_inbox` - - `ma_pay_callback_log` - -适合后续继续做: - -- 表结构核对 -- 初始化数据检查 -- 回调与订单状态排查 -- 开发联调时快速确认通道配置 - -### Playwright MCP - -- 可进行浏览器打开、点击、表单填写、快照、截图、网络请求分析 -- 适合后续验证: - - 管理后台登录流程 - - 通道配置页面交互 - - 提交支付后的跳转页/表单页 - - 回调相关前端可视流程 - -### MCP 资源浏览 - -- 可列出 MCP 资源 -- 可读取资源内容 -- 当前未返回资源模板 - -### 非 MCP 但对开发有用的本地能力 - -- Shell 命令执行 -- 工作区文件读写 -- 代码补丁编辑 - -## 4. 业务模型总览 - -### 4.1 商户模型 - -- 表:`ma_merchant` -- 作用:定义商户主体 -- 关键字段: - - `merchant_no` - - `merchant_name` - - `funds_mode` - - `status` - -### 4.2 商户应用模型 - -- 表:`ma_merchant_app` -- 作用:商户可创建多个应用,每个应用具备独立 `app_id` / `app_secret` -- 关键字段: - - `merchant_id` - - `api_type` - - `app_id` - - `app_secret` - - `app_name` - - `status` - -当前代码中,`app_id` 既是应用标识,也是外部协议鉴权入口;`epay` 兼容链路直接用它作为 `pid`。 - -### 4.3 支付方式模型 - -- 表:`ma_pay_method` -- 作用:维护支付方式字典 -- 当前库内数据: - - `alipay` - - `wechat` - - `unionpay` - -### 4.4 支付插件模型 - -- 表:`ma_pay_plugin` -- 作用:把“支付通道配置”与“PHP 插件实现类”解耦 -- 插件需要同时实现: - - `PaymentInterface` - - `PayPluginInterface` - -当前代码里已有两个插件类: - -- `app/common/payment/LakalaPayment.php` -- `app/common/payment/AlipayPayment.php` - -但当前数据库只注册了 `lakala`,还没有把 `alipay` 作为活动插件注册进现网开发库。 - -### 4.5 支付通道模型 - -- 表:`ma_pay_channel` -- 作用:把“商户应用 + 支付方式 + 插件 + 参数配置”绑定起来 -- 关键字段: - - `merchant_id` - - `merchant_app_id` - - `plugin_code` - - `method_id` - - `config_json` - - `split_ratio` - - `chan_cost` - - `chan_mode` - - `daily_limit` - - `daily_cnt` - - `min_amount` - - `max_amount` - - `status` - - `sort` - -这正对应你描述的核心业务特点:一个应用下可配置多个支付通道,每个通道可挂接不同插件与参数。 - -### 4.6 支付订单模型 - -- 表:`ma_pay_order` -- 作用:统一存放系统支付订单 -- 关键特性: - - 系统订单号:`order_id` - - 商户订单号:`mch_order_no` - - 幂等唯一键:`(merchant_id, merchant_app_id, mch_order_no)` - - `extra` JSON 用于存放 `notify_url`、`return_url`、`pay_params`、退款信息等 - -### 4.7 回调与通知模型 - -- `ma_callback_inbox`:回调幂等收件箱 -- `ma_pay_callback_log`:回调日志 -- `ma_notify_task`:商户异步通知任务 - -这三张表说明项目已经为“渠道回调幂等 + 日志留痕 + 商户通知补偿”预留了比较完整的基础设施。 - -## 5. 代码分层与关键入口 - -### 外部接口入口 - -- `app/http/api/controller/EpayController.php` -- `app/http/api/controller/PayController.php` - -### 支付主流程服务 - -- `app/services/api/EpayProtocolService.php` -- `app/services/api/EpayService.php` -- `app/services/PayService.php` -- `app/services/PayOrderService.php` -- `app/services/ChannelRouterService.php` -- `app/services/PluginService.php` -- `app/services/PayNotifyService.php` -- `app/services/NotifyService.php` -- `app/services/PaymentStateService.php` - -### 支付插件契约 - -- `app/common/contracts/PaymentInterface.php` -- `app/common/contracts/PayPluginInterface.php` -- `app/common/base/BasePayment.php` - -### 管理后台接口 - -- 商户:`MerchantController` -- 商户应用:`MerchantAppController` -- 支付方式:`PayMethodController` -- 插件注册:`PayPluginController` -- 通道:`ChannelController` -- 订单:`OrderController` -- 系统配置:`SystemController` -- 登录认证:`AuthController` - -## 6. 当前已落地的对外接口 - -### 路由现状 - -当前 `app/routes/api.php` 实际挂载的对外接口为: - -- `GET|POST /submit.php` -- `POST /mapi.php` -- `GET /api.php` -- `ANY /notify/{pluginCode}` - -### 兼容协议现状 - -当前真正已打通的是 `epay` 风格接口: - -- `submit.php`:页面跳转支付 -- `mapi.php`:API 下单 -- `api.php?act=order`:查单 -- `api.php?act=refund`:退款 - -### OpenAPI 现状 - -`PayController` 中存在以下方法: - -- `create` -- `query` -- `close` -- `refund` - -但当前都还是 `501 not implemented`,并且对应路由尚未挂载,因此“通用 OpenAPI”目前仍是预留骨架,不是已上线能力。 - -## 7. 核心支付链路 - -### 7.1 Epay 下单链路 - -1. 商户调用 `submit.php` 或 `mapi.php` -2. `EpayProtocolService` 负责参数提取与校验 -3. `EpayService` 使用 `app_secret` 做 MD5 验签 -4. 构造统一内部订单数据 -5. `PayOrderService` 创建订单,并通过联合唯一键保证幂等 -6. `ChannelRouterService` 根据 `merchant_id + merchant_app_id + method_id` 选取通道 -7. `PluginService` 从注册表解析插件类并实例化 -8. 插件执行 `pay()` -9. `PayService` 回写: - - `channel_id` - - `chan_order_no` - - `chan_trade_no` - - `fee` - - `real_amount` - - `extra.pay_params` -10. 转换成 `epay` 所需返回结构给调用方 - -### 7.2 回调处理链路 - -1. 第三方渠道回调 `/notify/{pluginCode}` -2. `PayNotifyService` 调插件 `notify()` 验签与解析 -3. 通过 `ma_callback_inbox` 做幂等去重 -4. 状态机更新订单状态 -5. 写入回调日志 -6. 创建商户通知任务 - -### 7.3 商户通知链路 - -1. `NotifyService` 根据订单 `extra.notify_url` 创建通知任务 -2. 通知内容写入 `ma_notify_task` -3. `sendNotify()` 使用 HTTP POST JSON 回调商户 -4. 若商户返回 HTTP 200 且 body 为 `success`,视为通知成功 - -## 8. 插件与通道现状 - -### `LakalaPayment` - -状态:示例插件 / mock 插件 - -现状: - -- `pay()` 已实现,但只是返回模拟二维码字符串 -- `query()` 未实现 -- `close()` 未实现 -- `refund()` 未实现 -- `notify()` 未实现 - -这意味着当前库里虽然已经能“创建订单并拿到拉起参数”,但还不能完成真实的拉卡拉闭环。 - -### `AlipayPayment` - -状态:代码层面相对完整 - -已实现: - -- `pay()` -- `query()` -- `close()` -- `refund()` -- `notify()` - -特点: - -- 基于 `yansongda/pay` -- 支持产品类型: - - `alipay_web` - - `alipay_h5` - - `alipay_scan` - - `alipay_app` -- 可根据环境自动选产品 - -注意: - -- 当前开发库没有注册 `alipay` 插件记录 -- 当前通道也没有指向 `AlipayPayment` - -所以它虽然写在代码里,但当前数据库并没有真正启用它。 - -## 9. 管理后台现状 - -后台已经覆盖以下核心维护能力: - -- 验证码登录 + JWT 鉴权 -- 商户管理 -- 商户应用管理 -- 支付方式管理 -- 支付插件注册管理 -- 支付通道管理 -- 订单列表 / 详情 / 退款 -- 系统基础配置管理 - -这部分说明“支付中心后台”已经不是空架子,而是可以承接后续运营配置的。 - -## 10. 当前开发库快照(基于 2026-03-13 实际查询) - -### 数据量 - -- `ma_admin`: 1 -- `ma_merchant`: 1 -- `ma_merchant_app`: 1 -- `ma_pay_method`: 3 -- `ma_pay_plugin`: 1 -- `ma_pay_channel`: 2 -- `ma_pay_order`: 1 -- `ma_notify_task`: 0 -- `ma_callback_inbox`: 0 -- `ma_pay_callback_log`: 0 - -### 当前商户与应用 - -- 商户:`M001 / 测试商户` -- 应用:`1001 / 测试应用-易支付` -- 应用类型:`epay` - -### 当前活动插件 - -- `lakala -> app\\common\\payment\\LakalaPayment` - -### 当前通道 - -- `lakala_alipay` -- `lakala_wechat` - -### 当前示例订单 - -- 订单号:`P20260312160833644578` -- 商户单号:`TEST123` -- 状态:`PENDING` -- 通道:`channel_id = 1` -- `extra.pay_params` 为 mock 二维码 - -## 11. 当前代码与需求的对应关系 - -你给出的项目特点,与当前实现的对应情况如下: - -### 已匹配的部分 - -- 多商户:已支持 -- 一个商户多个应用:已支持 -- 一个应用多个支付通道:已支持 -- 通道可绑定支付方式:已支持 -- 通道可绑定支付插件:已支持 -- 通道可存储插件参数:已支持 -- 通道可配置手续费:已支持,当前会参与 `fee` / `real_amount` 计算 -- 商户通过 `APPID` 发起支付:已支持,当前主要在 `epay` 兼容链路中落地 -- 创建订单并调用第三方插件:已支持 - -### 仅完成“数据建模”,尚未完全落地执行的部分 - -- 每日限额:字段已存在,但当前下单/路由流程未校验 -- 每日笔数限制:字段已存在,但当前未校验 -- 最小/最大金额限制:字段已存在,但当前未校验 -- 更复杂的路由策略:当前仅按 `sort` 取第一条可用通道 -- 多协议统一 OpenAPI:控制器骨架存在,但未真正接入 - -## 12. 后续阅读建议 - -如果下一次继续开发,建议优先从以下文件继续进入: - -- 支付入口:`app/http/api/controller/EpayController.php` -- 协议适配:`app/services/api/EpayProtocolService.php` -- 业务主流程:`app/services/PayService.php` -- 订单创建:`app/services/PayOrderService.php` -- 回调处理:`app/services/PayNotifyService.php` -- 插件管理:`app/services/PluginService.php` -- 拉卡拉插件:`app/common/payment/LakalaPayment.php` -- 支付宝插件:`app/common/payment/AlipayPayment.php` -- 通道配置:`app/http/admin/controller/ChannelController.php` - diff --git a/doc/project_progress.md b/doc/project_progress.md deleted file mode 100644 index 26a1ba9..0000000 --- a/doc/project_progress.md +++ /dev/null @@ -1,320 +0,0 @@ -# MPay V2 Development Progress - -更新日期:2026-03-13 - -本文档用于记录当前项目完成度、明显缺口和建议推进顺序,方便后续继续开发时快速接手。 - -## 1. 当前总体判断 - -项目已经完成了“支付中台基础骨架 + 后台配置能力 + Epay 协议首条链路”的主体搭建。 - -更准确地说: - -- 数据模型已经比较完整 -- 后台配置能力已经具备可用性 -- 支付流程主链路已经跑通到“下单并返回拉起参数” -- 真正需要继续补的是“真实渠道闭环、规则执行、异步补偿、通用协议扩展” - -## 2. 已完成 - -### 2.1 基础框架与环境 - -- Webman 项目骨架已搭建 -- MySQL / Redis / JWT / Cache / Event / Redis Queue 依赖已接入 -- 管理后台与 API 路由已拆分 - -### 2.2 管理后台能力 - -- 验证码登录 -- JWT 鉴权中间件 -- 管理员信息查询 -- 菜单与系统配置读取 -- 商户 CRUD -- 商户应用 CRUD -- 支付方式 CRUD -- 支付插件注册 CRUD -- 支付通道 CRUD -- 订单列表 / 详情 / 后台发起退款 - -### 2.3 核心支付数据结构 - -已建表并落地: - -- 商户 -- 商户应用 -- 支付方式 -- 插件注册 -- 支付通道 -- 支付订单 -- 回调日志 -- 商户通知任务 -- 回调幂等收件箱 - -### 2.4 下单主链路 - -已打通: - -- Epay 参数校验 -- Epay MD5 验签 -- 商户应用识别 -- 幂等订单创建 -- 通道路由 -- 插件实例化 -- 插件下单 -- 订单回写支付参数 -- 返回兼容 Epay 的响应结构 - -### 2.5 支付状态基础设施 - -- 订单状态机服务已存在 -- 成功 / 失败 / 全额退款关单状态迁移已定义 -- 回调日志记录能力已存在 -- 回调幂等收件箱已存在 -- 商户通知任务创建逻辑已存在 - -### 2.6 插件体系 - -已建立统一插件契约: - -- `PaymentInterface` -- `PayPluginInterface` -- `BasePayment` - -说明后续继续接入新渠道时,整体扩展方式已经明确。 - -## 3. 部分完成 - -### 3.1 Epay 兼容是“主路径”,但还不是“全量兼容” - -当前已实现: - -- `submit.php` -- `mapi.php` -- `api.php?act=order` -- `api.php?act=refund` - -但 `doc/epay.md` 中提到的一些能力,如 `query`、`settle`、`orders` 等,代码中暂未实现。 - -### 3.2 支付宝插件代码较完整,但未在当前数据库启用 - -现状: - -- `AlipayPayment.php` 已实现 -- 当前开发库 `ma_pay_plugin` 中只有 `lakala` - -这意味着支付宝更多处于“代码已写好、配置未接入”的状态。 - -### 3.3 回调后通知商户的基础逻辑存在,但补偿闭环还不完整 - -已完成: - -- 创建通知任务 -- 发送通知 -- 失败重试时间计算 - -待确认 / 待补齐: - -- 当前没有看到明确的任务投递入口 -- 也没有看到定时调度 `NotifyMerchantJob` 的配置闭环 -- `NotifyMerchantJob` 虽然存在,但尚未形成明确的可运行消费链路 - -更保守地说,商户通知补偿链路还没有真正闭环。 - -## 4. 待完成 - -### 4.1 通用 OpenAPI - -当前状态: - -- `PayController` 只有骨架 -- `create/query/close/refund` 都返回 `501` -- `OpenApiAuthMiddleware` 已存在,但未挂到路由 - -建议判断:这是下一阶段最适合补完的能力之一。 - -### 4.2 拉卡拉真实对接 - -当前状态: - -- `LakalaPayment::pay()` 只返回 mock 二维码 -- `query/close/refund/notify` 全部未实现 - -影响: - -- 现在只能用于打通订单创建流程 -- 还不能进行真实线上支付联调 - -### 4.3 通道路由规则执行 - -数据库已设计的字段很多,但运行期并未全部生效: - -- `daily_limit` 未校验 -- `daily_cnt` 未校验 -- `min_amount` 未校验 -- `max_amount` 未校验 -- `split_ratio` 当前只存储,未看到清算分账逻辑 -- 路由策略目前只是“按排序取第一条可用通道” - -这块是项目从“能下单”走向“可运营”的关键缺口。 - -### 4.4 Epay 协议映射细节 - -当前内部支付方式代码使用: - -- `alipay` -- `wechat` - -但传统 Epay 常见值通常还有: - -- `wxpay` -- `qqpay` - -当前代码里没有看到统一别名映射层,说明“协议兼容”仍偏接口形态兼容,而不是完整字段语义兼容。 - -### 4.5 插件注册与初始化数据同步 - -代码、SQL、数据库现状存在轻微偏差: - -- `database/dev_seed.sql` 里准备了 `alipay` 和 `lakala` -- 当前开发库只看到 `lakala` - -建议后续把“代码存在但数据库未启用”的状态统一起来,减少联调歧义。 - -### 4.6 通道安全与敏感配置 - -当前通道配置直接存在 `config_json` 中,后续建议补充: - -- 敏感字段加密存储 -- 后台展示脱敏 -- 配置变更审计日志 - -### 4.7 测试体系 - -当前仓库里没有看到成体系的: - -- 单元测试 -- 协议测试 -- 插件对接测试 -- 回调幂等测试 -- 退款回归测试 - -这会让后续迭代的回归成本越来越高。 - -## 5. 风险与注意点 - -### 5.1 当前“多通道”能力更偏配置层,而不是调度层 - -虽然表结构和后台已经支持多通道,但运行时路由还比较简单,不能完全体现: - -- 限额控制 -- 金额区间控制 -- 通道健康度切换 -- 优先级与容灾 - -### 5.2 退款能力目前偏基础版 - -当前退款服务已存在,但从实现上看: - -- 更适合单次退款 / 全额退款 -- 全额退款后直接把订单关闭 -- 没有独立退款单模型 -- 没有完整的部分退款累计能力 - -### 5.3 回调成功后的订单与通知一致性要继续加强 - -当前已经有: - -- 幂等收件箱 -- 状态机 -- 通知任务表 - -这是很好的基础。 - -但真正生产级还建议再补: - -- 事务边界说明 -- 异常重放工具 -- 回调人工补单工具 -- 通知签名 - -## 6. 建议优先级 - -### P0:优先补完,直接影响可用性 - -1. 实现真实渠道插件,至少先补完一个可联调通道 -2. 补完 OpenAPI 主链路 -3. 在路由阶段执行金额限制 / 限额 / 笔数规则 -4. 打通商户通知任务的实际调度与重试闭环 - -### P1:补齐可运营能力 - -1. 增加支付方式别名映射,提升 Epay 兼容度 -2. 把 `AlipayPayment` 正式接入插件注册与通道配置 -3. 增加后台对通道能力、产品、环境的可视化说明 -4. 增加日志检索与问题排查手段 - -### P2:走向平台化 - -1. 增加更多协议兼容层 -2. 增加清算 / 分账 / 对账 -3. 增加风控规则 -4. 增加监控、告警、报表 - -## 7. 建议后续开发方向 - -### 方向一:先做“一个真实可用通道” - -建议优先把某一个通道做成完整闭环: - -- 下单 -- 回调 -- 查单 -- 关单 -- 退款 - -这样项目就能从“框架完成”升级为“真实可上线联调”。 - -### 方向二:补通用 OpenAPI - -原因: - -- 你已经明确后续可能兼容更多接口 -- 当前通用控制器和鉴权中间件已经有雏形 -- 补完之后,项目会从“单协议适配器”升级为“统一支付网关” - -### 方向三:把通道路由做成真正的策略引擎 - -建议把下面这些字段从“仅存储”升级为“真实执行”: - -- 金额范围 -- 单日限额 -- 单日限笔 -- 通道优先级 -- 通道健康状态 -- 权重或降级策略 - -### 方向四:补测试与排障工具 - -优先建议增加: - -- 下单幂等测试 -- 回调幂等测试 -- 退款状态测试 -- 协议字段兼容测试 -- 一键重发通知工具 - -## 8. 推荐继续开发顺序 - -如果下一次直接继续往下做,我建议按这个顺序推进: - -1. 选定一个真实渠道作为首个闭环目标 -2. 补完该插件的 `notify/query/refund/close` -3. 接入并验证商户通知补偿链路 -4. 在 `ChannelRouterService` 前后补齐通道规则校验 -5. 正式实现 `PayController` -6. 抽象协议适配层,准备支持更多接口 -7. 增加测试与后台排障能力 - -## 9. 当前一句话结论 - -这是一个“骨架已经成型、第一条协议已打通、非常适合继续往生产级推进”的支付中台项目;下一阶段的重点不是重写,而是把已有设计真正补成闭环。 diff --git a/doc/skill.md b/doc/skill.md deleted file mode 100644 index d8f8699..0000000 --- a/doc/skill.md +++ /dev/null @@ -1,312 +0,0 @@ -# MPAY V2 项目技术栈与结构文档 - -## 1. 项目概述 - -MPAY V2 是一个基于 Webman 后端框架和 Vue 3 前端框架的支付管理系统,核心聚焦支付业务:商户管理、通道配置、统一支付、易支付兼容、商户通知等。管理后台提供管理员认证、菜单、系统配置、通道与插件管理;对外提供 OpenAPI 与易支付标准接口。 - -## 2. 技术架构 - -### 2.1 后端技术栈 - -| 类别 | 技术/框架 | 版本 | 用途 | 来源 | -|------|-----------|------|------|------| -| 基础框架 | Webman | ^2.1 | 高性能HTTP服务框架 | composer.json | -| PHP版本 | PHP | >=8.1 | 开发语言 | composer.json | -| 数据库 | webman/database | ^2.1 | 数据库操作 | composer.json | -| 缓存 | Redis | ^2.1 | 缓存存储 | composer.json | -| 缓存 | webman/cache | ^2.1 | 缓存管理 | composer.json | -| 认证 | JWT | ^7.0 | 管理员认证 | composer.json | -| 验证码 | webman/captcha | ^1.0 | 登录验证码 | composer.json | -| 事件系统 | webman/event | ^1.0 | 事件管理 | composer.json | -| 配置管理 | vlucas/phpdotenv | ^5.6 | 环境变量 | composer.json | -| 定时任务 | workerman/crontab | ^1.0 | 定时任务 | composer.json | -| 队列 | webman/redis-queue | ^2.1 | 消息队列 | composer.json | -| 验证 | topthink/think-validate | ^3.0 | 数据验证 | composer.json | -| 容器 | php-di/php-di | 7.0 | 依赖注入 | composer.json | -| 日志 | monolog/monolog | ^2.0 | 日志管理 | composer.json | -| 控制台 | webman/console | ^2.1 | 命令行工具 | composer.json | - -### 2.2 前端技术栈 - -| 类别 | 技术/框架 | 版本 | 用途 | 来源 | -|------|-----------|------|------|------| -| 基础框架 | Vue | ^3.5.15 | 前端框架 | package.json | -| 语言 | TypeScript | ^5.2.2 | 开发语言 | package.json | -| 构建工具 | Vite | ^6.3.5 | 构建工具 | package.json | -| UI框架 | Arco Design | ^2.57.0 | 界面组件库 | package.json | -| 状态管理 | Pinia | ^2.3.0 | 状态管理 | package.json | -| 路由 | Vue Router | ^4.3.0 | 前端路由 | package.json | -| HTTP客户端 | Axios | ^1.6.8 | API调用 | package.json | -| 表单生成 | @form-create/arco-design | ^3.2.37 | 动态表单 | package.json | -| 图表 | @visactor/vchart | ^1.11.0 | 数据可视化 | package.json | -| 国际化 | vue-i18n | 10.0.0-alpha.3 | 多语言支持 | package.json | -| 工具库 | @vueuse/core | ^12.4.0 | 实用工具 | package.json | -| 二维码 | qrcode | ^1.5.4 | 二维码生成 | package.json | - -## 3. 项目结构 - -### 3.1 后端目录结构 - -``` -d:\phpstudy_pro\WWW\mpay\mpay_v2_webman\ -├── app/ # 应用代码 -│ ├── common/ # 通用代码 -│ │ ├── base/ # 基础类 -│ │ │ ├── BaseController.php -│ │ │ ├── BaseModel.php -│ │ │ ├── BaseRepository.php -│ │ │ └── BaseService.php -│ │ ├── contracts/ # 契约/接口 -│ │ │ ├── PayPluginInterface.php -│ │ │ └── AbstractPayPlugin.php -│ │ ├── constants/ # 常量 -│ │ ├── enums/ # 枚举 -│ │ ├── middleware/ # 中间件(Cors, StaticFile) -│ │ ├── payment/ # 支付插件实现 -│ │ │ └── LakalaPayment.php -│ │ └── utils/ # 工具类(JwtUtil 等) -│ ├── events/ # 事件 -│ ├── exceptions/ # 异常(BadRequest, NotFound, Validation 等) -│ ├── http/ -│ │ ├── admin/ # 管理后台 -│ │ │ ├── controller/ -│ │ │ │ ├── AuthController.php -│ │ │ │ ├── AdminController.php -│ │ │ │ ├── MenuController.php -│ │ │ │ ├── SystemController.php -│ │ │ │ ├── ChannelController.php -│ │ │ │ └── PluginController.php -│ │ │ └── middleware/ -│ │ │ └── AuthMiddleware.php -│ │ └── api/ # 对外 API -│ │ ├── controller/ -│ │ │ ├── PayController.php # OpenAPI 支付接口(骨架) -│ │ │ └── EpayController.php # 易支付接口(submit.php/mapi.php/api.php) -│ │ └── middleware/ -│ │ ├── EpayAuthMiddleware.php -│ │ └── OpenApiAuthMiddleware.php -│ ├── jobs/ # 异步任务 -│ │ └── NotifyMerchantJob.php -│ ├── models/ # 数据模型 -│ │ ├── Admin.php -│ │ ├── Merchant.php -│ │ ├── MerchantApp.php -│ │ ├── PaymentMethod.php -│ │ ├── PaymentPlugin.php -│ │ ├── PaymentChannel.php -│ │ ├── PaymentOrder.php -│ │ ├── PaymentCallbackLog.php -│ │ ├── PaymentNotifyTask.php -│ │ └── SystemConfig.php -│ ├── repositories/ # 数据仓储 -│ │ ├── AdminRepository.php -│ │ ├── MerchantRepository.php -│ │ ├── MerchantAppRepository.php -│ │ ├── PaymentMethodRepository.php -│ │ ├── PaymentPluginRepository.php -│ │ ├── PaymentChannelRepository.php -│ │ ├── PaymentOrderRepository.php -│ │ ├── PaymentNotifyTaskRepository.php -│ │ ├── PaymentCallbackLogRepository.php -│ │ └── SystemConfigRepository.php -│ ├── routes/ # 路由 -│ │ ├── admin.php -│ │ ├── api.php -│ │ └── mer.php -│ ├── services/ # 业务逻辑 -│ │ ├── AuthService.php -│ │ ├── AdminService.php -│ │ ├── CaptchaService.php -│ │ ├── MenuService.php -│ │ ├── SystemConfigService.php -│ │ ├── SystemSettingService.php -│ │ ├── PluginService.php # 插件注册与实例化 -│ │ ├── ChannelRouterService.php # 通道路由(按商户+应用+支付方式选通道) -│ │ ├── PayOrderService.php # 订单创建、幂等、退款 -│ │ ├── PayService.php # 统一下单、调用插件 -│ │ ├── NotifyService.php # 商户通知、重试 -│ │ └── api/ -│ │ └── EpayService.php # 易支付业务封装 -│ ├── validation/ # 验证器 -│ │ ├── EpayValidator.php -│ │ └── SystemConfigValidator.php -│ └── process/ # 进程(Http, Monitor) -├── config/ # 配置文件 -├── database/ # 数据库脚本 -│ └── mvp_payment_tables.sql # 支付系统核心表(ma_*) -├── doc/ # 文档 -│ ├── skill.md -│ ├── epay.md -│ ├── payment_flow.md -│ ├── validation.md -│ └── payment_system_implementation.md -├── public/ -├── resource/ -│ └── mpay_v2_admin/ # 前端项目 -├── .env -└── composer.json -``` - -### 3.2 数据库表结构(`database/mvp_payment_tables.sql`) - -| 表名 | 说明 | -|------|------| -| ma_merchant | 商户表 | -| ma_merchant_app | 商户应用表(api_type 区分 openapi/epay/custom) | -| ma_pay_method | 支付方式字典(alipay/wechat/unionpay) | -| ma_pay_plugin | 支付插件注册表(plugin_code 为主键) | -| ma_pay_channel | 支付通道表(merchant_id, merchant_app_id, method_id 关联) | -| ma_pay_order | 支付订单表(status: 0-PENDING, 1-SUCCESS, 2-FAIL, 3-CLOSED) | -| ma_pay_callback_log | 支付回调日志表 | -| ma_notify_task | 商户通知任务表(order_id, retry_cnt, next_retry_at) | -| ma_system_config | 系统配置表 | -| ma_admin | 管理员表 | - -### 3.3 前端目录结构 - -``` -resource/mpay_v2_admin/ -├── src/ -│ ├── api/ -│ ├── components/ -│ ├── layout/ -│ ├── router/ -│ ├── store/ -│ ├── views/ -│ │ ├── login/ -│ │ ├── home/ -│ │ ├── finance/ -│ │ ├── channel/ -│ │ ├── analysis/ -│ │ └── system/ -│ ├── App.vue -│ └── main.ts -├── package.json -└── vite.config.ts -``` - -## 4. 核心功能模块 - -### 4.1 支付业务流程约定 - -1. **订单创建**:`PayOrderService::createOrder`,支持幂等(merchant_id + merchant_app_id + mch_order_no 唯一) -2. **通道路由**:`ChannelRouterService::chooseChannel(merchantId, merchantAppId, methodId)` 按第一个可用通道 -3. **统一下单**:`PayService::unifiedPay` → 创建订单 → 选通道 → 实例化插件 → 调用 `unifiedOrder` -4. **商户通知**:`NotifyService::createNotifyTask`,`notify_url` 从订单 `extra['notify_url']` 获取 -5. **通知重试**:`NotifyMerchantJob` 定时拉取待重试任务,指数退避 - -### 4.2 支付插件接口 - -- `app/common/contracts/PayPluginInterface.php` -- `app/common/contracts/AbstractPayPlugin.php` -- 示例实现:`app/common/payment/LakalaPayment.php` - -插件需实现:`getName`、`getSupportedMethods`、`getConfigSchema`、`getSupportedProducts`、`init`、`unifiedOrder`、`refund`、`verifyNotify` 等。 - -### 4.3 后端核心模块 - -| 模块 | 主要功能 | 文件位置 | -|------|----------|----------| -| 认证 | 管理员登录、验证码 | AuthController, AuthService | -| 管理员 | 获取管理员信息 | AdminController, AdminService, Admin 模型 | -| 菜单 | 获取路由菜单 | MenuController, MenuService | -| 系统 | 字典、配置管理 | SystemController, SystemConfigService | -| 通道管理 | 通道列表、详情、保存 | ChannelController, PaymentChannelRepository | -| 插件管理 | 插件列表、配置 Schema、产品列表 | PluginController, PluginService | -| 易支付 | submit.php/mapi.php/api.php | EpayController, EpayService | - -### 4.4 前端核心模块 - -| 模块 | 主要功能 | 位置 | -|------|----------|------| -| 布局 | 系统整体布局 | src/layout/ | -| 认证 | 登录、权限控制 | src/views/login/ | -| 首页 | 数据概览 | src/views/home/ | -| 财务管理 | 结算、对账、发票 | src/views/finance/ | -| 渠道管理 | 通道配置、支付方式 | src/views/channel/ | -| 数据分析 | 交易分析、商户分析 | src/views/analysis/ | -| 系统设置 | 系统配置、字典管理 | src/views/system/ | - -## 5. API 接口设计 - -### 5.1 管理后台(/adminapi) - -| 路径 | 方法 | 控制器 | 功能 | 权限 | -|------|------|--------|------|------| -| /adminapi/captcha | GET | AuthController | 获取验证码 | 无 | -| /adminapi/login | POST | AuthController | 管理员登录 | 无 | -| /adminapi/user/getUserInfo | GET | AdminController | 获取管理员信息 | JWT | -| /adminapi/menu/getRouters | GET | MenuController | 获取路由菜单 | JWT | -| /adminapi/system/getDict[/{code}] | GET | SystemController | 获取字典 | JWT | -| /adminapi/system/base-config/tabs | GET | SystemController | 获取配置标签 | JWT | -| /adminapi/system/base-config/form/{tabKey} | GET | SystemController | 获取表单配置 | JWT | -| /adminapi/system/base-config/submit/{tabKey} | POST | SystemController | 提交配置 | JWT | -| /adminapi/channel/list | GET | ChannelController | 通道列表 | JWT | -| /adminapi/channel/detail | GET | ChannelController | 通道详情 | JWT | -| /adminapi/channel/save | POST | ChannelController | 保存通道 | JWT | -| /adminapi/channel/plugins | GET | PluginController | 插件列表 | JWT | -| /adminapi/channel/plugin/config-schema | GET | PluginController | 插件配置 Schema | JWT | -| /adminapi/channel/plugin/products | GET | PluginController | 插件产品列表 | JWT | - -### 5.2 易支付接口(对外 API) - -| 路径 | 方法 | 控制器 | 功能 | 说明 | -|------|------|--------|------|------| -| /submit.php | ANY | EpayController | 页面跳转支付 | 参数:pid, key, out_trade_no, money, name, type, notify_url 等 | -| /mapi.php | POST | EpayController | API 接口支付 | 返回 trade_no、payurl/qrcode/urlscheme | -| /api.php | GET | EpayController | 订单查询/退款 | act=order 查询,act=refund 退款 | - -易支付约定:`pid` 映射为 `app_id`(商户应用标识),`key` 为 `app_secret`。 - -## 6. 命名与约定 - -### 6.1 模型与仓储命名 - -- 业务语义命名:`PaymentMethod`、`PaymentOrder`、`PaymentChannel` 等,不使用 `ma` 前缀 -- 表名仍为 `ma_*`,通过模型 `$table` 映射 - -### 6.2 订单相关字段 - -- 系统订单号:`order_id` -- 商户订单号:`mch_order_no` -- 商户ID:`merchant_id` -- 商户应用ID:`merchant_app_id` -- 通道ID:`channel_id` -- 支付方式ID:`method_id`(关联 ma_pay_method.id) - -### 6.3 商户应用 api_type - -用于区分不同 API 的验签与通知方式:`openapi`、`epay`、`custom` 等。 - -## 7. 开发流程 - -### 7.1 后端开发 - -1. **环境**:PHP 8.1+,Composer,MySQL,Redis -2. **依赖**:`composer install` -3. **数据库**:执行 `database/mvp_payment_tables.sql` -4. **配置**:复制 `.env.example` 为 `.env` -5. **启动**: - - Linux:`php start.php start` - - Windows:`php windows.php start` - -### 7.2 前端开发 - -1. **环境**:Node.js 18.12+,PNPM 8.7+ -2. **依赖**:`pnpm install` -3. **开发**:`pnpm dev` -4. **构建**:`pnpm build:prod` - -## 8. 相关文档 - -| 文件 | 说明 | -|------|------| -| doc/epay.md | 易支付接口说明 | -| doc/payment_flow.md | 支付流程说明 | -| doc/payment_system_implementation.md | 支付系统实现说明 | -| doc/validation.md | 验证规则说明 | -| database/mvp_payment_tables.sql | 支付系统表结构 | - -## 9. 总结 - -MPAY V2 以支付业务为核心,采用 Webman + Vue 3 技术栈,后端分层清晰(Controller → Service → Repository → Model),支持支付插件扩展与易支付兼容。管理后台基于 JWT 认证,提供通道、插件、系统配置等管理能力;对外提供易支付标准接口(submit/mapi/api),便于第三方商户接入。 diff --git a/doc/validation.md b/doc/validation.md deleted file mode 100644 index 904899e..0000000 --- a/doc/validation.md +++ /dev/null @@ -1,395 +0,0 @@ -验证器 webman/validation -基于 illuminate/validation,提供手动验证、注解验证、参数级验证,以及可复用的规则集。 - -安装 -composer require webman/validation -基本概念 -规则集复用:通过继承 support\validation\Validator 定义可复用的 rules messages attributes scenes,可在手动与注解中复用。 -方法级注解(Attribute)验证:使用 PHP 8 属性注解 #[Validate] 绑定控制器方法。 -参数级注解(Attribute)验证:使用 PHP 8 属性注解 #[Param] 绑定控制器方法参数。 -异常处理:验证失败抛出 support\validation\ValidationException,异常类可通过配置自定义 -数据库验证:如果涉及数据库验证,需要安装 composer require webman/database -手动验证 -基本用法 -use support\validation\Validator; - -$data = ['email' => 'user@example.com']; - -Validator::make($data, [ - 'email' => 'required|email', -])->validate(); -提示 -validate() 校验失败会抛出 support\validation\ValidationException。如果你不希望抛异常,请使用下方的 fails() 写法获取错误信息。 - -自定义 messages 与 attributes -use support\validation\Validator; - -$data = ['contact' => 'user@example.com']; - -Validator::make( - $data, - ['contact' => 'required|email'], - ['contact.email' => '邮箱格式不正确'], - ['contact' => '邮箱'] -)->validate(); -不抛异常并获取错误信息 -如果你不希望抛异常,可以使用 fails() 判断,并通过 errors()(返回 MessageBag)获取错误信息: - -use support\validation\Validator; - -$data = ['email' => 'bad-email']; - -$validator = Validator::make($data, [ - 'email' => 'required|email', -]); - -if ($validator->fails()) { - $firstError = $validator->errors()->first(); // string - $allErrors = $validator->errors()->all(); // array - $errorsByField = $validator->errors()->toArray(); // array - // 处理错误... -} -规则集复用(自定义 Validator) -namespace app\validation; - -use support\validation\Validator; - -class UserValidator extends Validator -{ - protected array $rules = [ - 'id' => 'required|integer|min:1', - 'name' => 'required|string|min:2|max:20', - 'email' => 'required|email', - ]; - - protected array $messages = [ - 'name.required' => '姓名必填', - 'email.required' => '邮箱必填', - 'email.email' => '邮箱格式不正确', - ]; - - protected array $attributes = [ - 'name' => '姓名', - 'email' => '邮箱', - ]; -} -手动验证复用 -use app\validation\UserValidator; - -UserValidator::make($data)->validate(); -使用 scenes(可选) -scenes 是可选能力,只有在你调用 withScene(...) 时,才会按场景只验证部分字段。 - -namespace app\validation; - -use support\validation\Validator; - -class UserValidator extends Validator -{ - protected array $rules = [ - 'id' => 'required|integer|min:1', - 'name' => 'required|string|min:2|max:20', - 'email' => 'required|email', - ]; - - protected array $scenes = [ - 'create' => ['name', 'email'], - 'update' => ['id', 'name', 'email'], - ]; -} -use app\validation\UserValidator; - -// 不指定场景 -> 验证全部规则 -UserValidator::make($data)->validate(); - -// 指定场景 -> 只验证该场景包含的字段 -UserValidator::make($data)->withScene('create')->validate(); -注解验证(方法级) -直接规则 -use support\Request; -use support\validation\annotation\Validate; - -class AuthController -{ - #[Validate( - rules: [ - 'email' => 'required|email', - 'password' => 'required|string|min:6', - ], - messages: [ - 'email.required' => '邮箱必填', - 'password.required' => '密码必填', - ], - attributes: [ - 'email' => '邮箱', - 'password' => '密码', - ] - )] - public function login(Request $request) - { - return json(['code' => 0, 'msg' => 'ok']); - } -} -复用规则集 -use app\validation\UserValidator; -use support\Request; -use support\validation\annotation\Validate; - -class UserController -{ - #[Validate(validator: UserValidator::class, scene: 'create')] - public function create(Request $request) - { - return json(['code' => 0, 'msg' => 'ok']); - } -} -多重验证叠加 -use support\validation\annotation\Validate; - -class UserController -{ - #[Validate(rules: ['email' => 'required|email'])] - #[Validate(rules: ['token' => 'required|string'])] - public function send() - { - return json(['code' => 0, 'msg' => 'ok']); - } -} -验证数据来源 -use support\validation\annotation\Validate; - -class UserController -{ - #[Validate( - rules: ['email' => 'required|email'], - in: ['query', 'body', 'path'] - )] - public function send() - { - return json(['code' => 0, 'msg' => 'ok']); - } -} -通过in参数来指定数据来源,其中: - -query http请求的query参数,取自 $request->get() -body http请求的包体,取自 $request->post() -path http请求的路径参数,取自 $request->route->param() -in可为字符串或数组;为数组时按顺序合并,后者覆盖前者。未传递in时默认等效于 ['query', 'body', 'path']。 - -参数级验证(Param) -基本用法 -use support\validation\annotation\Param; - -class MailController -{ - public function send( - #[Param(rules: 'required|email')] string $from, - #[Param(rules: 'required|email')] string $to, - #[Param(rules: 'required|string|min:1|max:500')] string $content - ) { - return json(['code' => 0, 'msg' => 'ok']); - } -} -验证数据来源 -类似的,参数级也支持in参数指定来源 - -use support\validation\annotation\Param; - -class MailController -{ - public function send( - #[Param(rules: 'required|email', in: ['body'])] string $from - ) { - return json(['code' => 0, 'msg' => 'ok']); - } -} -rules 支持字符串或数组 -use support\validation\annotation\Param; - -class MailController -{ - public function send( - #[Param(rules: ['required', 'email'])] string $from - ) { - return json(['code' => 0, 'msg' => 'ok']); - } -} -自定义 messages / attribute -use support\validation\annotation\Param; - -class UserController -{ - public function updateEmail( - #[Param( - rules: 'required|email', - messages: ['email.email' => '邮箱格式不正确'], - attribute: '邮箱' - )] - string $email - ) { - return json(['code' => 0, 'msg' => 'ok']); - } -} -规则常量复用 -final class ParamRules -{ - public const EMAIL = ['required', 'email']; -} - -class UserController -{ - public function send( - #[Param(rules: ParamRules::EMAIL)] string $email - ) { - return json(['code' => 0, 'msg' => 'ok']); - } -} -方法级 + 参数级混合 -use support\Request; -use support\validation\annotation\Param; -use support\validation\annotation\Validate; - -class UserController -{ - #[Validate(rules: ['token' => 'required|string'])] - public function send( - Request $request, - #[Param(rules: 'required|email')] string $from, - #[Param(rules: 'required|integer')] int $id - ) { - return json(['code' => 0, 'msg' => 'ok']); - } -} -自动规则推导(基于参数签名) -当方法上使用 #[Validate],或该方法的任意参数使用了 #[Param] 时,本组件会根据方法参数签名自动推导并补全基础验证规则,再与已有规则合并后执行验证。 - -示例:#[Validate] 等价展开 -1) 只开启 #[Validate],不手写规则: - -use support\validation\annotation\Validate; - -class DemoController -{ - #[Validate] - public function create(string $content, int $uid) - { - } -} -等价于: - -use support\validation\annotation\Validate; - -class DemoController -{ - #[Validate(rules: [ - 'content' => 'required|string', - 'uid' => 'required|integer', - ])] - public function create(string $content, int $uid) - { - } -} -2) 只写了部分规则,其余由参数签名补全: - -use support\validation\annotation\Validate; - -class DemoController -{ - #[Validate(rules: [ - 'content' => 'min:2', - ])] - public function create(string $content, int $uid) - { - } -} -等价于: - -use support\validation\annotation\Validate; - -class DemoController -{ - #[Validate(rules: [ - 'content' => 'required|string|min:2', - 'uid' => 'required|integer', - ])] - public function create(string $content, int $uid) - { - } -} -3) 默认值/可空类型: - -use support\validation\annotation\Validate; - -class DemoController -{ - #[Validate] - public function create(string $content = '默认值', ?int $uid = null) - { - } -} -等价于: - -use support\validation\annotation\Validate; - -class DemoController -{ - #[Validate(rules: [ - 'content' => 'string', - 'uid' => 'integer|nullable', - ])] - public function create(string $content = '默认值', ?int $uid = null) - { - } -} -异常处理 -默认异常 -验证失败默认抛出 support\validation\ValidationException,继承 Webman\Exception\BusinessException,不会记录错误日志。 - -默认响应行为由 BusinessException::render() 处理: - -普通请求:返回字符串消息,例如 token 为必填项。 -JSON 请求:返回 JSON 响应,例如 {"code": 422, "msg": "token 为必填项。", "data":....} -通过自定义异常修改处理方式 -全局配置:config/plugin/webman/validation/app.php 的 exception -多语言支持 -组件内置中英文语言包,并支持项目覆盖。加载顺序: - -项目语言包 resource/translations/{locale}/validation.php -组件内置 vendor/webman/validation/resources/lang/{locale}/validation.php -Illuminate 内置英文(兜底) -提示 -webman默认语言由 config/translation.php 配置,也可以通过函数 locale('en'); 更改。 - -本地覆盖示例 -resource/translations/zh_CN/validation.php - -return [ - 'email' => ':attribute 不是有效的邮件格式。', -]; -中间件自动加载 -组件安装后会通过 config/plugin/webman/validation/middleware.php 自动加载验证中间件,无需手动注册。 - -命令行生成注解 -使用命令 make:validator 生成验证器类(默认生成到 app/validation 目录)。 - -提示 -需要安装 composer require webman/console - -基础用法 -生成空模板 -php webman make:validator UserValidator -覆盖已存在文件 -php webman make:validator UserValidator --force -php webman make:validator UserValidator -f -从表结构生成规则 -指定表名生成基础规则(会根据字段类型/可空/长度等推导 $rules;默认排除字段与 ORM 相关:laravel 为 created_at/updated_at/deleted_at,thinkorm 为 create_time/update_time/delete_time) -php webman make:validator UserValidator --table=wa_users -php webman make:validator UserValidator -t wa_users -指定数据库连接(多连接场景) -php webman make:validator UserValidator --table=wa_users --database=mysql -php webman make:validator UserValidator -t wa_users -d mysql -场景(scenes) -生成 CRUD 场景:create/update/delete/detail -php webman make:validator UserValidator --table=wa_users --scenes=crud -php webman make:validator UserValidator -t wa_users -s crud -update 场景会包含主键字段(用于定位记录)以及其余字段;delete/detail 默认仅包含主键字段。 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..352871e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +version: "3" +services: + webman: + build: . + container_name: docker-webman + restart: unless-stopped + volumes: + - "./:/app" + ports: + - "8787:8787" + command: ["php", "start.php", "start" ] \ No newline at end of file diff --git a/public/admin/index.html b/public/admin/index.html new file mode 100644 index 0000000..25ce86e --- /dev/null +++ b/public/admin/index.html @@ -0,0 +1,21 @@ + + + + + + + + + 管理后台 + + + + +
+
+
+
+
+ + + diff --git a/public/cashier/index.html b/public/cashier/index.html new file mode 100644 index 0000000..0dec423 --- /dev/null +++ b/public/cashier/index.html @@ -0,0 +1,13 @@ + + + + + + 收银台 + + + + +
+ + diff --git a/public/mer/index.html b/public/mer/index.html new file mode 100644 index 0000000..d3e83cd --- /dev/null +++ b/public/mer/index.html @@ -0,0 +1,21 @@ + + + + + + + + + 商户后台 + + + + +
+
+
+
+
+ + + diff --git a/public/storage/uploads/image/2026/04/10/20260410105634_55bfa9769bdcf5b9.png b/public/storage/uploads/image/2026/04/10/20260410105634_55bfa9769bdcf5b9.png new file mode 100644 index 0000000000000000000000000000000000000000..0344a73a70e71a04be8cf497886c5c35be123765 GIT binary patch literal 82117 zcmbTdbC4!Mw>Q|fHEr9r-P5*h+qTVVb9&mgZQC}U?rA*J-hRJ(@3$KpU+f=SQIU}+ z>nKm2%*y&zRkV_V6e1ih90&*qqKve-3J3@|@pt_S1_}fO^t65uH?}laF*i2zcAhZj|CX|_R?~9Rl9%H(b+l(R{tpkMm%Y=sGzbX4 zke8FOsjayiv5C2*wSxdDpsSCR*xF2hRFgxVS>8#^+{#+o$HiRLM?uZh$JUg`j8sUF znBR-{TYE^#99+r%M}xSztEr2% zlbf}p1Mz<}8k;z}y9tnfd-`8puy>M||KExoT>m>z-yvi2GInBOVPs~qxBpMS{uj5a zn~M4W?Z*F++f~im$(%{W+||+D#q@hTEXe*x_&aw0--iCf`0Wj^qKozSpcvbUJDR%N zn>)D4hzpQ@Utu(}Hsj?HV`F3H=HOvr7U!1yj;E+33y&lRm$->V zOLB0Fv4}`YuyAp*ut+kCh;gupvv5hWh)Xi_a4@s|kF1P?tDCWdsrmodwf?sI-?9?_ zpR&ASF6PE=jxK7Bj&}c(0ZLYmZjP>2j!wj4s+`2sn$`|xj-IYG{~4bD>aDoBi?xTj znWT%OJ@NmFFR%6g!9I(a1Q!n{J2Q*KfAyP3f}Kr5L|l|vjD<%;gq4Hzzh%w-|HO>x z+Zm?+B**_FS^lT#I|Khy{okqoP5kf4WA5-hV_d$c#uRAU8we=>lZ?2Cn%CyF4!o1v z3Xb5}tpg`sioBetXrp$@9STa6YJJqtk&yY~X=ci##nlkZpO`jMI!bp7!plL!Q8hZc zaMCe4hG8mD*kPM$=J>I^3~EZ)gHvJMTB#Hh4vmRRUwyuFr`J2ao{plwlqoGw3w&=k z9twQtzCPw$Gg(K+)#shiV}o1$mbgqPHKj^4Cj_I!p*GjHL?>O&g>XQKCzn@Pj!#*SrhjC^z(c{nLcqe(nziA15>e$Re)y&ciN;oOuPqh7=R+c zgQOX)hjj7ld&Q5C(H?*FX?;396&QA@3P7ieDklNm(hgSXP8+)ohPtP?iE~ksMk!wf z$L||Mu50^lwDDX5H3AHa>~dWY#YOUk{BuJ_?V;jzOUsIC;>)3(Qb-S(!M_3uHu@S9*i?A z`F0wxq~7+T&)Jg?E%_dcv~X_x%{alkL3c}0jsBcyU6`PLqg)wd*$D)QI=Oi<=DQf9 z{fG{}1H^&;tAuR*uR~LAt_+ZYf<*AZ&yAB_-GL})`o%3T&&o3``V3UcRO{}!Gd>I$ zi0n7R$e?~#mwveSB<-iL0@7k#~^_WBjv$vOi^E`CK) zqChpgd;E3kV0l{`BcIiI1H3u{ki?27OiME4Qxjv;N=A(~30OT4Pp zx&^q)k4iB)N;pt9ahM=!E0J!%lgi=!xhg%|X&8`zfZ)u#ujR=EWIK%rb`;#9jnC;O z3pOCR?e7KE&AMlY-qwmO)%S7l?Kz$84gV~{Otl;ovc!rN-T0_j&Rr=8doP zDekq&Fs#M=Ot)_HJ|De}pM#l!p(g&)o%uq^(&bB2m{`Ma@kX@~oOTVLoFq88SFj$vPGOExxREJXP%70l#d=J!k6XG^oBsVK4c>639e((dPJ69W3g`gE_ zz{vEp!wgKA;LZK(IKJ0kn#b+@xg;d8b2`4CFbPX@Hy7PNoKkZ0mw%j{9p_;5&G zx?m2-XgPlx_f5=Xt%n+f1#@>K8n-dF0HMB9$2&>yHE58YK9#0>af$6!3hcbQEwh~> z^q%+wI7#Q5-TPSMqS7~HzrFLH%9va4a{S`+3Gi3COcKDsx$r#4bP-(N4}YK{-Q}q> zVizNRS~Tn-Wi^!B)ir^N4Zc})({*u6r?q#Ze8k`xib zuL{Nwa>gP%jHTEvXoxJrJp9A+II$K13Y;zsOg0NZrk;-Zu#ERH6pM%Kr@d!1<$CNR z8`ypq4i5e0yrju+M)`M-Zwn7_d_i;Oew=7>8AMPdZYhZTm2reWF@4$_sB(FJUddud z0)$uf^?^wRcS3-_e=`fx1NkZG>nnY?(;o?0TA&-{M4D@L!+g^D1r`<| zXrdnuvl|TLW@hk^(B*#H@OwYU$&m`&6(SEjD}=3(|0(j~&q37JXM3ACde@+AO0{~J zB`|bwG1WA};-}G_8m`?GE9=BBz*kV=opQYajQ~1GXIHuCw7| zZs6aGzQlW;6Z1rcF&5y6&sOWDX6$l^dI4CJsZD)4vMzk{GxJGCPHOVlid)7gAKD`1 zuo)3Z zt@ZL+7j%IB-T5WviVlB+aC8C*``=-WI5OjdY*GesH~lOOVwQRgaxGcl9gD`+%d>3` z34=T>k)qDJUj&1&V4~tmo%#L*%vIkrjBw0GmGWWfH^qcgV|3P{= zageNDov&!n6YHN-Dqj#TYjHS`4fOsv@@Xubvsv(||JG!2*m{H7ZL#J?=%rr)svV-3{4Y*Xx<&-hJ`mRbtt+0P2# zQ)7t7Z~RQe$NSIkxyp}tNT>98Yiw*k0#MG07@7K_&xFX&I2|VKBS)D3{a#hVE_G(S zjhi}2L4CrCJ;AJ_NJ+hqQShG?F%Ra>r58o6f7IJsAOGt>^Op<^5fwEU(&fnXWQn`V zqx;;ffxp;wb=|I2y!m83IOFbL%=-Fya$cT(Iqnr5o0Ukr9)XY+{m0Tp8?Vw%1lni` zfiW)*b6^Z`A&|>`arvU`Y!(Z+Sz+6PQZJ7WGm02HGg^;SzGedRDgH=7IdIX`oi?OY-pCHWR8kcmu7Q7FuA8D zex?0Vg>fPa4sQ$F!(L-!+D{uDQTjzx^qPC7jNj;tS0!b z)y;aJCs4fkpV67!>(|g=nP6BZ_V6mtL)QL4MvM<0HNfuoc20W;||}VW+aT?Y0{6X{Ic$ zH}#8#Iz{}49+)}#JcD&UMCEfjPcB{LLF_n!TW2;!SH=jbXE>@x?jLjF5`XPv;jhm2 zWw8DF(ogI;kC5{7e_~E}E*+N9^(}NFpUqxXPGRm{q7FqyC!4wH5%V!}_%|zIF=jnv zqp|e)Uv*J|3zQ$*-8UM*ibn_Z0gRyG)uPKSD_cuK4~7OI0-3m7Ma0O#NMKE+qyQn< zDvOe-0G)&%;m&vS$)FwHkTW4f6&3EHf2Q@id}N-<81=d_I0LrXa+CL(8JNzZ7QTk0 zG0J87N;t!)oqM>5%ZE2>sa_E^ zg^i_;j*Y#X&=4aGP;OLf6c9%r&E|IfA>cqgTd*b~ufc?<>9XL=k$OAfndQ7A3j}&w zF+Gs%M_rgR<%JK#fm&lMKg)Ijzr-icU`X$sq6FvCf!Rw9ItqhVS-g!)+2-F^Jj$ z?Bv|V@N16^$2nv(>3zTnxOfr=!0#2^loPz{Sq-3dfDp9>dHl|3JdqZ%_)JUJW?sra z)AsrFy~qC+=YP!3=BtD;V(#N^Qfjr5zA##0jE%dZqc-3E*esz~p=G?akf2EY^n+52 ztUjB~6y&x1OnD$w1QKtTPk8!mHK1SFI#9>(Pzp{Up(@Ael!l%_hFJlT>YD9?xeQ&S zIQ;sP+*w$_J*Z&~XsXIv*iiKAe)bLdE1-nLQ|=|ZX`&&B_~`Vkc%d4d>Zsd@Z=ohzgMN?I2+gDh#^1HT3b&XAha0s+Q|9 zv!0fTAFB4oubDR%E_IfAL(pswS-uF?ff76_+uQ47=|e}2m6$}n<}2nhy7l8Y-7Z<3)gGVk`S4jH%U1#T?j}&U z*67m?cI>Sl-{OVx-hTb!r;)$su@M$yVtfBBS`14y@AR?FI`M?a=meauz`@%!VIK~*s_HP2-sZr3DA-}p#-}i`EcObx zzp|zP+Ut9$y)py(5kA1}RW$1K`%-azaAMC6BjYe?nbtG5?#JtuIlSy-KYeq`=JNA+ zn&j;%lawAMBUH@1$2$RGNJp?TA*H;hc~6k3;k&*R;8SA1p6a>QL%?7;D?i9UeAhq;Mw}xo0i|N1LAYr0V4xyt7`(8fEHczZ zVU)~Z;%CPFjVyyCX;P>tOL{?44X8UXWZ#cd(56u$K@C=c;>taIB&DftAlmh9Gg2Pk!sf>j+m9`w+FGx3!YSq;tSc^^lIQC_W?g2_Vp1^G6{yYeUkTU>02)5&0*IE28V{!_&m!PU-_*cjQkxe z@WLbUetl6U&JyFFY2!eG9}c;&Sz4l@%2AA?Qs3bnzH=qe1MlF~x;R9^7GQDuafJbq z!#q%8_Z=o;?gNIhB@1c~%2mE8A0tphB!BS$wjsce#f=x;wm>rF@+GDuFf?Zh)6lYP z39S7}j4Z)nl}75ov0T}pbUX}|uLfl6md*|KLVXaal_ss+$UHG8*Rtb}JBwHha=-gU z;f{ieQ;zIRyprG>t44z4GxhEUr(W##d`v8ZF{XAQj-}ipZH@)cecdYuGc$4YRkYIT)KY#sMxi!I|6~Hlt6$;1p?`4j-odcX#WF2Tytb1eoIY*#o8D} zU`tL8n`aTC_qf;0biJ1`G?BM>V>frhN;s(O-SxaQ7B_Tc=HvKu->m8 z3{dh_$Q&+uG#a`i;^LP1iGfiA4cgt$CQ%1N8CYC1@F+}rq&>O&stC%;@6;bapZSWLYa^gR?q2A2 zjzNrAy!`wuX36%jlkF5^Jf4x!Gm0(Ejm)Yx(6ZG69GN62Qjup zZ~8-vePXNLT?dHGkzvN>9RM~sDso?!ER99G@28}s#O3jEQZ?QdM8~_yYIFhyw}?I<8t(CLT5k)1XWEFtpy3Ezqz6{w5Vc ze+elpw*>@XR_b|0aM$_;_x4kG3ta>L-%!`wM2B+&GJ72Qd_W4FaXXx(}E z!T5q0i?{Ymo&g4f%g>6a%noHaRn~aCu~EzfYO$IW4k2FJ&zLQ$PUn~3C>dkn9S<;< z9<|}euKk8)*FX$vBWZTfu+e@vA_t2!0@N*b?HV`>t&tCBg;I+SNQ8)%!A=2DWT2yJ zrGiuXYOSJ<;+EigvV7c#iA9!>rsVp{nfXrxyXh_G&@N9v>U2)*UN&DKDn|*%NfJ2{ zNYDY<*hv0|pg_2w$9uc-4cghv>>s)E=olMvx5EK3sn6TNk*TxsXOe~z{MC!ZIJ{N6 ziNhDqN|(FW>r@I)CvP6sC^SBp+pI`gggOaIGD$F1<_DS`HNArGq6le{>Yyl`s$I*R9;Ken84YhjPIpmUi3CE) zDtzo}tUxm}wKVXl(wUcvgtDOoLjTXfksFgS`;E>cRz_<2>gPt;3+twIJ)++H&%Ljt zfc`oijuBv5H`@s(BQ+J5CU7SCqou2NWhBmk%mog`G8!$MJp3eMB!W{Z%3THwD*=DZ zzCUPF*eq(FILzh2?rQ7zo6WqNDbAs}Ihy2ze}j%93aaYD;%b5MULtOTU?;FA=W?PVa((NAzG;&jXM>+Qux&AqHE z>{9pdcY)XQwUO&`;o6P9+KCQa;d@V{B@NdW-VGfc-c3&a#@4Pw<57JDJnnY+xi*>z z_>v_T!%Ne;oNd`b{XVf`Vxeb??t?>vAyV;q)R)@8^M#|()5Z|c+g_^I?1PR-jjR}} zNwa~GbUQMu*-17K)Rs;*;pCX)XIDk{^t{y2%dkkz7nb}&^e@5a&W{V^I+oZntV|LZ z+3;yJdciZWDM-{qMB3f4$hHp2khqGrNeUDLbZ`>rqi>+iK-3J4a+1so!q!h&vzrR> zgGkw&_jL*cdcFz)Ny5h9?zD#5iX4IoRW^!FW1YOi&J4HfXhrm#cwBiKMO!rl3To53 zdeGU@@VXGn;)tK6Y&uE-N8Ya!Qk@?S(dUYSNgaz=P*C*Jq4fEHl^b)DBpX?iv8+vp z{BLdyC_)SJ9E8w8V|={<3d5G#K+25f8~5o#%hX)03j{2bZuuf>0u!4HOw9M*iX}*k zzEM%^c^?nb{AU2hidT~@-su0GRN1&LZ!{tfH zcoin_-mV9y=~UiCY-tGt8`oK#ez0&stDzMdpZtRNh^luB?$dE6e>gk;wjN5CnpW2 zZnyF}f$#%^HB0a$IU664FNr$Hi3E#i2K%*X0vyp@Py}$9tJ? z_0!0>Xk2-h)IDTmxa>jCx~);IBq;o`Pd*2)W;bX=3?JO+33)tZ2ZoOg&3w?eZf(Lt zkWYivtwp@6z%|KkNKOW@--VyjPsowO-y-!Vc^FMZ;3 zaTCx~FhRzD?=m(l+F^Mj)i<4D$hUeA!6AHC#8~+Kq!>SbFpbXz%wKkVTC-yqm*xn3 z6++JfYt`~R?6-2?Tyz(qhZpKr>i9-e{ON@Beb|KzePLSzJulr*{A-f3#d03%Id?R^ zL>jKH`#nFK=Plk8`hJM&2L-Cy96pNRHiqIkf$S(pWC7pX-(JP{jU}x-r0gPR@c7n1^!92b#u~3$|N7NpgV3 zq_nK)40P4jR`yiq_rNqa7f|w);DHFug=Z{xfn{uGl!8eL!*d}rbD911>+|wSPO;Lr z)7OVGHR|o@?SnS;qT=?lwl=V*#Gt3#C8?kwzyAaP_EzwdR#**f-b#aE}*MBsHY0e*4M zKqRCSY?jAwe%$OH>H^x&iokt6t3Ng*=&_qg=9ncKdez3pv$W?Em;ij}OM;`?%YNV0 z*jE}A5#0{Nf}cFwjlN4AlVMIzSC#wuN3|ZZfuyv(UHt30OSUH`X>f4Ri|6Mllb3ao zq?oJbZ=v_Au0XiIw+XTz%)kjlA@P`@_?8oynrTo>=uYW=5Q^Xkp3MNs1F)R3U2(R! z53Gv4xziIMc^{Wi$9shG;?6&q8;n~wm7a~><8~;rIhlG=A%5OTcXqWKzI9{#_1{UG zoxC5b_FG@{Pj5nP0Ke3CBoLCN>TtYx^qzli9WM8FffCnTa$gxXM_Yhr|BY#ZO7ENi zqr?a;IL?Mg+dtH^%RcB@VdN}Y5e^RRO}G=0WRxs{u)v^c*l3Ka`VrYx^i z(Xt%kp@I(s#6W@j7G~|Md2M?-RO(zPu2!~L-gLCIsIW+d zWRbl&3#-IWbSB|CRD*?Aput#rVJX1*nYRh#>l>La&n$3sL(KWX!QF>AK{5t zN5xIax+G#w39T05WxZ}spAicEm;i&=)vNkL>eiZz@}m4cubq-*N~xJ>LI8 z_Rhz&H{Mytl`%Rp^7m3rLnDvs5q)>@GR+#9ext?z0Sp;fpZfPbw?mOH^aQ&`o~PWU z#bv)(w}1X{5n zrN z)hPA{{b4dV-W5gSA!m00m`5adI%a-3|C-Cbtu;7iMcAP#^*9kW2~4i!aA&v#*H5BC z<)SjWi<;Bw@!!PrA<0>PqvIhxb5Z8(O2kbrR0h|zhdnin+ls;BBSZ$UYJNWf`^3LL zcVqf8HkPXUgMrtTm2<+j==(#frqvdO5*qGv?{fiK)&V^vRDg*x0y3qXaGRTnY`GFM zP?oH3Y;+ibyf5;??L1Qw`+yW6lBro{!bV*O0qj@QC36;j2Pt(Y?z*i)?n#+qn=5gYKP_gopM`K zM(*7?h6EOYtA2;ZM39lh$fRh2^{Se#ElP};Rw+38eQxKa3E}#9+U)mNTqy@~JPK5t zRdi8*qS!&jFQ@PWs*1eUA}Ph*hpBh>31MPlT{X% zyQEyiZM9h0lW>jwUOT1b%Ke^Ngn$7zE17UG6zv4sfO0Zua?M_*e7+HzDZ^?lg1+Af(gRE8U$^FL5sTTf{egX7y-=5MdytIuTBHzbko34KL zjapO1QKI_reQwX^uw&w0t)wG;wRpv-z{QPav$TUta6fvDWSM6}c4+4&Jx(eTMZ3Y! zK<^S90miCCW_}iGOdIRf0J|(AbX67+6^D`j4-= zDKDRBs$I+nV44x_?dt>3y+Ykkhnp#C8nbhVo#sP2Hj|IgIBhV4g$Hfh_mc}lzJ`HL z&qu#-NfkXWS{`CL^sE;kE7|UIQQ`?>Z6q|A@-K+1`Hq4&mSl{;4*;@=0UG(nH@_<; z4u(PgRcns+^SHQ^>@wmYbMx@X0LL`<&hJibnSSh?gZzpz{}@sD0cPNZmMJArKnRIS zl2e02(q7QuBJ%K$g+w1H-H6YK4$cW@`}9DN zu8dR7GfOlNnUX8tl#q)nS*M38`>4v zlkOa=#~)~tM1#@qaI#3>FgehtqU80#KJ}2#Wf6fvLivzOU&ClA!@$SK)Sn0xUf0-) z)&l+g2#(^^!95PDpz1&^`WIKg*dMHH$IhbtH=NPV@P*YWaol{7a!Bg=lbj>MPYKX9 zH{$_kl$5^@@TZ5!yR*-Az3+bb`3>&5=csTYIB5MrhOe#Z1%ZEu+q(eOD470}lgSZI z`S|=+{d#joq0Nix$;rvlgRZo6RS9?@Zp~G+J=dveP_ZGn+>$z(?Vxm-oje%BW&X^+ zzKn>Ge58?!-@=^J{M{e^vAt1=c;ZRqcGk3UNLd&Flt(D#~=5FGmTuoIzX#(`K&m(F)!u`MXb?e`91h6TvSx zwKWC})t!erDOy_@uQ3Bf*lB-X4fee!Ld%}6G?a%TEh64Kn}v#&97*xBdnS#-AoRp| z6$??p!%qXC3DRb25@ONWtWCLHW!kf6-^lw#v8v*AOq*249UCA&d+TfWOTq%~7RsqM zH~a60Rr|czc#8yvwCfH{OdapDq~c4f=Tt9Kc~@nzL2^YxOX^|h)dFS=G1Jvo>{qyI zK#?SV@|FnWH)t&cTFu6X{e^_H4I<@^*@($KfSQY&3tAUTeq-+hS3S5OlGZUkR zTJsVQyiL}q%+0I`?bm0pR$c`gVg8sV=aTk4X%cDo=|pNdAsU#dLkhTe;Bl03ZF#}3 zJ#{%EuaiXhqp)Jq27tz7M(L-c^k_))~GlXxyvWmYi zpNQ+_Vx91Lz{++izkQq%z|NGT@BX** zwK~PyM4{_0?Vpa$+}TQ*jCrSoJdZm+O!6yH8!F^Py8+j|f_PKsL#zv%*H7moLi8rX zw@;TNOLo6^7zJLn^%*ls5eaWCNB)|_9*ymc#HjM{bXdKIRm3Tj`$39~rv8JjnmNtO zJ?{3N%bi*Jv>8;xa$i}Ygs>H0Ax)LFIx=Zs>the9-=33>*2wi5aqvSspmsun${Z@R z)JjcEmA~1(4ui=lxn@lK6Q1T+&APIH=z>>;I~_bj%w{3Ln(&U4Q7|}VDKr|2K6pDG zW3caVh$>M?r6M4au^R};n?9Wwi{Yul`ZW_@^&HrBv>+1t>Pw`aRgMqE4I&AHlm%ie z62C?IzF&=_wa90JZ9oIdw=v{s1vWudVR8SZAm7`>Rzys%@ z_sS*{X20vl7DUleYVrln4LR2_9* zHiCjZX#^n>%s%InH>Dc~wMG)icrSg7eagsJ)~-`H8}psnyAp0r94zQHb5kGDgTn4wvyG;PpZ@ik?m zBad{1hfWwgNFe0u`n4EEt-JGtU@WyO@GtK)-dEkKkgm*6lK`62+nYV-xL%XM!$vn- zv<67$r7(&#lU~EImAZYR_Sm??)tbcc$F`;5dFZG>n3ITOmTa&x4 zWZ)Kj*gjet=jit1k|{=&q?u81J98YJ&H6f&+8 zEEdH9?$>%+UR+1w8hL0R69Oj4B_3|B$_M-hi`{wB{3Wr3mS4aTA8Pn?IIVLZ#pKLl z?&p5mq6o}~c)5A+n0YmDL|yebh{&ixh04s65UmTTW3X1<#!rG_J=MV=@zb`VVWB!P z3Z@Ox3kk}XTZIOOjfsJDh7UXK+>?v=2B%bo7sm2aqMjoS_lR`$rMLvu<*L=e6zOKc z8EjK)q(6aokD>2Y;`VEoJt#P2$TcA*tPoTbKE3Ib1FmRsM*4zyxDZod?j}N|W`*@$ zPvCCO#sV1S!ZLiTPa`E6Jm}D>#J`5MntQBf*!h!WS)@N=3iF*IKJ6|16^fy}daAnC zcQ)D-^^`^wK2Ia|d4l#rTFt>=jbEUax6V<1qLr$-P)#X&0Uo%gy~ALzhBGk8HWA(q%iFsE(7kx z+BMyvOnrju$bvXY?VnN=D-h9OE3pPhmxWiIZmc|6> zaOf07f-oh^2rX|-nX)8jlsVz1w-y~5n!mhJKd7@3I>~HUs_D98**rTs6yorKD7Dc} zFrao$HxObd!|@LkA!s;j3TQSxtWfz$E|7{h^P!+rq6f}@0^h}v0DRhmq-f%EsLQDC z4d1Xb?&Ug1sHW#ZY@M6(>-9hFt8rHVK9_ZG{p|Hv{v}A&n;yZxJqMurzx_7U5&(ug z#X_wqlck!TPR*e2p}-sftD9SV9jUfSrTSw&M(dVFld8o=-UV5w6r!kl$cXSXBC;#! z3}it4@~#7`W5q3ueCtNI%?R1NAnx&`ejQSR2y0Z@y>-RHH1D-qqiSxpqOlkrgBx|$ z@UGZ1*2cs}^LKAik;8od=ioDIQO*0(*q`Mz3?d*JF${+MGVBlVy{xTKHVzIB%?{tS z3%8}FzFn`Ewz;09L%=T*S{XYzIgB=6=ZQlu)w#UByIr=guKuef7gj1R=dWM@JfG+7 zBl#Ls!1LFmG+-_&9Ez5a(OcOI-;hkFe2LbFZyq@r6*a28T$jsX_cc`lrBg_d(ZNGj zpfa8MA`b0V7`m!jHa9fb$T%vgBxL;d;AaPxOai6ax+v^a7gAu)JMESyXHNWD9j^_|7)=LkU2`swz8e_v~}t>Fl_Jpe>i>%2}gv>>&@O@ zXg+tXSL>;HqgV1}1_{pJ@Cw=XUPhpwDkZSqa`v=bObso{ZmuJJIxAm2zvnb|*_?>} zzH>(|i@#@4P1W_~&7V)`C?Wla;q3Z=1Jd6vi6K3-SK7U(!^zt5N@Y#Jo z)5X5GL|STz+htk zQ7_B%7cCEXUsyDrkcmF%wLb#}4e)<;{Lsq4K}~%HWN+%gxSg)S08I2^ZEbAe!^a#i z*V<%C3j#xVa9AZNxSElKQ7!?BcBm(W&FosoY3`_nVfQ?skx0)EQrZ#((X?0N2jXKP zH`P;`wa?kCH^E~K2)z!=>s`M#H+c)vAemDETOWvBmJqqnV>^82sp~BsfOXM>jSlp7 z^DnpKep|E~`NwA9PALuBOlcE2Nj?v$=@v_&sSygp#d5_Fv2k8aPLoi&4`tQ~#hFw# z7`uV`BA1E7InG7Tv_p7f5FGBOMl)Ej07Ma_b_}+>0W5%zH3(1 zghtIwO9w}%BP~{*xQiL<$yfh9pixF;RGADh8FhbnzaS1VV&$bFmt%Yx5YW!;kD-W$lvb1L*|Cv-W9-kzeEnq(=ClLvkdw(2Sb}xF~ zhT|c80l6W{cy5a64)q`+q5UbP@9e{D!?0^)A zV*b-twpvFE-t{1){cvZ0mdT$wyc3NN*P6YM??)z82N*benV2V^ zjd`czUkJ9L_SvUt>~TB@Op44$C&P$wa`uvlaB8aL zQy53~4V=)}fk-RqfN1LSRM@evKJvCkMmLcG#V?F78j9F0tAVKWil5cm|C$QJ6S`AA zI6il2IQez4;1h|q+fkUi|28NzgjHiJXaC+^7~BN5g2= zFh**@D3&ikAHy`7;<V&>RK~y7 z?RRR-BIPMs8Clpc)+RR+FSj?0rvuTg!TVgvADDlRKc$Cwc44Y1lz$K+j~w;ea}QGP z5=9FzThYqo(&A$Z`pb{}nOd>s3VHDpRG&kQ$Lo7YlR|M^sZ6M@wo~geI9gh(%e^w> z`=fz0=5l`VoYytetdpYfXXNO}MS_Tkxfc%w;+~zKUQL^DPS)qwbyi-U7@k0bPfO*C zfq_X&Prl<4ZS_xBVz6atN{KeqMPInZE(B8fpwP=GbdV$=5NTd$;??A)E}@Gf)lDfZ z!pQGbN8#q>tz6I*1rm`L-u6JHz4oioru$5m&g~UEj)Vnz^E0NT249AUO<;zHlUCZM z8~cT^JA8_W1lu{%tcszm3($f|7F25-tI$T8b8(Ko9s3whA znxafM&&_!F2NVL}j^GZSgc7mOPXT4`%-wEM?h1ly&6~&wvofDVU7^za&X95e8k7JI z;!cxj$$?<)^l)CZqv5-MJzqgZgO+|8DO!{Spm|=-4uM7oZ`qosHkl6NvGm1fQMJ&( z0tqv#D=A58OuXkFpYv9~vA1oT0V!V9fIBf2v?AKW?z;Dh$1hWm1&N@E+9zYAV#A~8 z>`Ha6W&GnSvG=ot{SizmW0Aww(e3_^sE@C&vcX%SA>eld3z>t<&fBxnjgPz&23{kZ1IHveyhf)<;rXZ?ngPt+V7qE?iIyo`cWla?^`8jVmXxqZ~d!q48K zQ0RPNN0NJc--^Nncv@~B6svAI1N^jlh&~D$tvYShjL(43)gomwqKdS6?r3Rfwox!n zC`dj%HkTMZBrH_lOol1htDi&bf{>%n&?nFa9Q9K#O;m^0x4LjZd1`pXFc422m zMn?X?ue$2;WR@e1zh2#)Zfgeu6$u3Y7hUfFBgr4N3-{PNHg{~>wsy2*+dH;x+s2Ny zW81cETetuD-tXq-CbyGLD!){9sw-8e&N&ax*8I1PO}7m>Z2^k9coeZ*yeV3o5-dzI zRU~n@X0)68D>&ni1H{)k8cocR1G{I5C~OXVH~olM9xMgd%V`suz!OjC71ow(XB>0$>&e7Btw$B?+E(^Kp+qf}ke zls6ci8%kpPm9Q1e5VNH$vQWIjh$U1eu$fwWCQK|hlA3=YlQ8d(xy6KGfdX&wmHrh3i@IA z>2o#|u<}bpD1N0x{7S9Y9*RsIbvfONb+XTECa`49;2C8=!I|Imz4ig^mbeCKba@PF zUxs!>%_Z1cRO02`05iwhI{e5=N(t$9z1+Iyw-|fSZmX|QMac5~x(L^(Ie*SGjom_? zE|(*jdtW^bZ)EZ>ol4s@Id9G$ws<4^@mB09_AW^?&b;M;&>u>T79lqgU^cpF!+3?N zzp-2?`5DcIgq3S257xWZ(9p0+tI!4r`k~n0(XlKvRaF$bxiJfD3_Gc}Ja0wORW4|U zrH?u*%ZU~XZ}jc2R!J@ixw!LjUKHpnG@AQ)XYU%k?J{jYT#kxJ2Gs|SUa5Ilqn2?~Ytu0)={qGQG zsZpxjgqBC}ex=zPlg!O;q2p_y4y=$~FF$9iT(y$??(r6g7&~PQ*QODI@Rr!~{elDp zsFK+6M6-c*;kO0|u5?h$W$M>rM?ifLGo&^`%Ei3A4 zQYB0N;2k-Y(|T1otlj+syubN4>^A?bX!Gmo5sDT+8j@=^Oz||X33jPuXcI|QrDUXB zZ8U1EgI0uFf|(p2XvZCOSX#YkRW8c+sSz$2A)ejc$HI9s9%8e2h+gcH{J|XSE4bU4 zQswtUU(`ZjS*-IOww*3YSWx;oH|)#)T7ViYH9b0NVGM*6NlwRfI?Q!HduQVj>B_%> z5LEoqP%_RxK1~)GYzDp7m>{+4HIOQ}CX3Cv5^gm_*jK2t)!7VB6L$-jbgK+*@1>%u zbyqqAx7-bJVWX0m;qsC28J3U&(%(eQ=e}3&ZbZZ!gc`hJLQs^XEGgl6wngo@;Xf5$ zOWr1b`Nehq=DB(GN(e8jzEan4Ql`$`12u%{^?(Y6Nq4)2l zFkMgK^IXm+RU|aF?j2I{!!|Sq%jl6=cZTwpi$}rY_1Gdy-psg+_EDRcr6mqV))E?q zaZFvWx-u7AT!bK~xxct#5yGf3>ArELKkp{;FGWAe4jP?t3^t&8D@>DwiQjN+k^~kX z|Cbpj-z)i>Bvh>(3_gFY72?&Us?}0HLswSy14<->$B-{RmMqEXa zSDpEebnhq04_}J7n@z#KX|Nm02IgDpjw2gx?VG2&x%7XZpP#iZs12^m7zG+`^U{j~ zcCj?5`b)&f{*2#oqcYPM5RjK!AcIm*>}dw>KX*YTAS+GdZ+&4BVa6 z8Hw*ch?}|AE9LGN_ZO!iE|(K>OH~0|VxWk$JEG!Vie{s+aJd=FAr4v@nFGUz&Zk=n z={a{njOO;(R0`OaygvR-O~fB{Y5WWl0kmv-os(#28*X4x!@DM`ISPU^+L&gg8fg>& zwI2~ zz$!A#r0|Y4D8P;x5RMG*8!`x4PO}SaTGGO!X>ojRrk`$kJ`?57A={VFddFgr)plLKwe>A zU0e(#iP^v)PI^6BKT+h$w|TvoZ}@O4SC^I!PaAIIG$`ruqx=p*$g#8GMdIZMz#IwV ziv);CrUseUSJIsQCWTPXiM!n%IpKKlupM|QQ~BxI1Q{O_LrPsk>Oi|~;kDhfMyl&= zoFynm0XM$-OW%DiZr7;b4^Mdym`ti+-HW5q4X5{!-qz*1D{ia1>SvQ>6{G{lpB`k? z`O#7*)LoT>g9Fij;b8M=cRf7+z;=YV(s8k{7{7)Ik4jU-^omIN|I(mv&C!?H2O`pQ z7E!I9ZORj_ywRFFc5&U5;b8p}B%8K1wOGCV1NBkPZh6KL5+N27lS)hHxSHFI$Lae7 zp08An^_QeN#~GKE3>bCPc2oO1AGh3YE9ib>b~L8U7CHCKC*Dj?ida}rny6s>)Qlbo zx>*^GM*D!n(zbgW^@e0{5 zd8Sb!`Hq>b21(OXQl5AK6W1ln136-2Wr9R*!!OSQUoD2@+1s@n8y&fPyc^xLswpe; zh(*44tA*>u9p`PHzX!iE&gK|bqA|btewFxR6Ku6_@mS8>ATsE5H3!g7SN<;d%&w1z710|DjTh_?NZQ0>Er$Zft@mE9Il;`mB4I0?@G(i$ZsjcVB%kc z$~!cXFf`(oVnyPCl5ty7UO!}YT~&Y#sfjcdiwK{7cOqX3ODUwvX=pB<_>s{%ClnZ| zgH!poo)(`?Ll%qzc9Wkt$|<{Ll%nb2LSHcypO@)lkv>{&e|hj5I^55jSv(@mudnE$ zZCV9*c%UR`y|zl6E7|b3tn{o6PiM-P`Pw|co`)I0B1u22;)8>OGf#__%UMiE!K@ax z=S#dX*A5r++-X5AJpS>$n@^k|*C!2t_eqhqO_@hHIG%^e`Ss8eZnr<@4K%H{TpK>W zxZXND?)b0sQ*zgv`p8F2?idW540pLXq*BVv!5?U+O@kJvw<0D@xt+YXdBT4ouk&syO;)p&;T{+A z6JZW0;9>Y(quUg0p;b3c)1>)nK5+B%+tkG5v)S%hO^tpd6h2nt?0^(yCao_AAtt87 zVqhi2`|h*|_y!)bDac5IDDM^sv?%qSDBJx``K?E`(Tqk@gnISJG*EHINKfB^Ekh-z#=2*Z3nem(j`*!e%i99LCvdu`Pv z>0n-^eZ7Q1GDeP3p^B6Oyy0P4Kq@3ZA*dZO%#~0VE%*3lV*19{bhO>MJ3jV(Y54OR zHa*?L@dtg_$hb}!c(G*_rGP0g+h6al%1Wzezq3d6CM*_w?s?erF4e!|1NE;*RXm>jVOX?A?YJMk*_8vC(6(q;SVY6k-ypw4wDh|FoP#QR zKfb#QuXgnJ1`iljn2 z*alG9^n6CedAva7`cZU!nN}GFr}ob29mO>HnaNlc(acHBS`3 zyST_Y&dDKYnphO8S{kw4YKztcW)?$lM*D5an5ZZOi>HeWmZ^%A)|U9WM9F}%7&|Rm zR3vD5;R@DW5t|>BWR(4WO2N`a5YhfUiCw(b5yTOG{-bd1y&S|Od1z8ExmI{H0Di*7 zJP#gqk*R44@n7(~5ZfSyMr9VFTS?EMwpz2G9aJgETsZ+cW1QbbrwL2B# zE;#aaxDpyEfLLp9Ge-S1u$RQr0FSWj@HKz@;?wH3*uL6i9r$$!h%3Op;b@-&wv&>{S5~$4tkZ^ua1r_5b6SfQNUsH z%Rh|jUzy4Yf8$SZEj(&qbQI8QYvz?D=~0a5(@sNPGvMSdgcp4}#frSUeh{DmwXlN8)OI9@u2lYbui=q3QPp*L)RnD0UYr!b+v&;TKUx?v5si^blh7;&(6%We&87}0@> zBvR!9HJVImM4V|Ostl-#f1H~hDRC(`>-*~^2qflY5(6WnKDyszV*m!Mpz}i zHbIVx5W-7aeGy1Vu%a;AyGt!%8%Tno1BnWpxvVR1#FpV5PeRO(RfgfC*Vw<6o$H_rWNUQ8?d~2ENLg$ zme!MUaPZapcBA&hb+0y?TsrQb&p^rScEOku@Q4;GeS*RVvHc#OoaD>OOy0=6YKsj0 zhO@J?TY^JN5c-^@bcoo@gvVwt`EG^7d`?pBHZ?J;%nWAJK_PV2&+sdz^O}kK)$jw4 zpL=+Cy zKrCcF_3Ip5uqotOrAkPsh^fDnXAN0Vuy&OUE$)|!^FU<-EPCV4vPi-(PXMK05OZJt zteulz0A)3<6=hH5-#2qczY*7!*j`8o4*5lM)-P7D6?91Za)^1~n#~jD*GlZ^&5aG} zjWrkLf=F3TC((-^(Q56+>m}U`wRBefLskBS-rP*9p+DoM(i0`ZXH0F-2nYyFTNS!g zDzLw2S1jR9h8kgEYxlD>b2h8d`jP%^m+N+}@uV=8Jo;6(qlYd@R)QQH(?;!?L z<)Q6!cJ3j2XLbyl9>+TtBgqfn)I)tTQg(sfmpU za3a}H>zJDtPA~o6RVj2Cowcq4>+nSPFZ_HH}3k6S**oPTMJD8R0^SOdx14 z3Wqy>6$bu6cf})8W24S5rg#Sq#4uwBg5rmnU`XtK!mbBAc|-xBIaK=t7n|+09!$JVY?9*Y1PrJC{12n89kndhhfRzZhj&lwa6R$mwMK*fa8lI~;?}2KH8U zw?Yy*B$4QN%rND1_n<+6X?9GJUOug*t}$hZWrko55Un*_z2<{pr&WgVbNz6+>eDPU z7l02hdmh{|vH9+O;e)&u(%tg5K=tbw05}9Ze)cco7=l?CM;Ln2Vha9@On0Q?R3die z@1KJI_ekJp1Wn?nnBH8*`U6A1@0g^+>XV}xK)n9{0YYO*)|vUkF78l$)(-YW&E`^u zVACVMXww0Or*=u|;O+QMCnj&I3Ov~8hfy-}vK zu4#>r>{zaL5G2ZuYXBMV5MC(ha-a#gr+9w`enEgCJ!JIGlj*xmvjXj3in*#MfL^CweWd@+}%Bp|)LqAw;3zNl{ z&3C0Ggy5e1_p7VQ*cVp@Nw!i-GHI^G1|1*+PWzCPv$cppW{f&NS#aUgy)4oL2WBQmDEV@juj_}!tYi$o~HzpP182daR6BY zT@fr_-;w}8fRbVLASmch<0PTxR{#YqtVLVSvMs**297Q_#SvPF!(+?ViK&pb&&dZY zoL1E+8(@V{9Nd~mXb~9#0jf7y>%er;J>`j|P|=V9_fvPeeW$o>hF zysYn%S_P$2m3l8vQ;uj~CFg+rhxQJp`a9450WK1&*|xvPEU=#F>|eV4M^&ZWxr8~E zv43&(?M*KNATY{!IDC+QAGIV-Wdy^zHa&jT&EhjZ;{Ewfn{8aPg04of-Zb zZmnA|pR&=ZLv_$dVuv)xAk^m!^wSAxOd%)<;gJXIx-_-**~mOLlT#mr21M_KA0?KC z!nOPQ@^;Ebb0`yj`=&s-NQhY%Sf;SRkN=LpbW8Fk(xgk4bZCgI zOCha%eShb|!XnXbPk;FEQATvV|Ja$`rzrCtz8ai;gf*^D`nzzfy1HPtkQTfC?RYkU z{ppRk(}iRiQdd3R0U>bW!VUqo{7U+@;(u7;4g}E_jesB;HzN2<7pr-oKTW(Be|JgU zZ*5LJ`}4Knbd~7<2Q?us?p#X^jn#1R?R=7?eLJNoMoO~=pt5wmt8#5r>OhfHV@;hjqc+^_j(~F28Z#jbP#TgG-N13)rv=`dk~l1SJ<;ry7;WVveJ>9 z@p9;911IFTuSpNqL78Gcd9lUUsXephDvRxdE?HU?D7*ii!2u`J>V3S$Q(aw+AWAIJ z|02Zb16^`!^wGuWE%43rY#>rN86tf`$n|h+wQP5CmYzs5IzBCRTicWiS^>p?1P!i- z`xVkP66n(g`Y9?Xz!vJ`z~Ui*zx$bGYKeGtT{iM%Ai$t2h?{~PRJG5~zonB$2<$i; zrpqL-TM=h)H<1gN(2%V4!_6iG={Q_i&>A`d*3vit1Afm<%B9AotwxLK>nb7UBuQ;7^}ibbR6RL4 zTia@weysWx00H4p3~UJ~V#JdeRtp0IPkN7T08i)kPxa}YE)c4F(n3f?25KPqh+pVX zft8F}&R~{U$+=_I#%#G@%bDlc(nSi1h=&Otsy7Jp#&a;zyZM;}k#4iA?dPlY@wu_| z@4NYWqhV>VP`aA#8WRqbfPb+FXqgc+%-?G`9AzDJjsV!rOE7Di8kIZ)o31MNtp>C- zsg55ls!*RHoNaxv9b)m;1}<4HR=5nBfXt&>!jjp1^L#6yJ;+IVjG^jm_pG!A2sQti zUm`V+KwVjKADXznBl;FB;ho9VfV`oEYjb=is31TS#_1v1y<*Gh%H_?=XuYoUMn)b4h#WYZDAj?KpBdDg~kedVi4BBz}w^RuW1y@94|dO%~kcGhRsO9uUs zLZsxy3W1gr>?h0!28;CMjV8oZJ}^8B4^uR&RD!99(Tl^0^tlZ^%ka!L_8VQJnKfS< znv~AQ$EPc%&X9uu*fWYcispXIw7q+L`~wxv?RZ^Hw~a5*R>S_q$HHQ@lVrj2UhjUC zcOvO@zvd2_#&mji)@KDCMe%iDk)LCb)#dVe^qUBwfRBd*xuGllX(i>UN~iOJX+f^# zX>xP()4NsMK$T0mRCdFT6B8CZyX=nF>jlemW;1#k9}kZ}imWCeZKDUC0PT_RA$Kaj zprs|Xz0$15!)hHH55)(i$QN_sraP0ZaH)l*blb$On)`Isd+*>ja8{+g0ZJ4>>u_(c z;C>yM$5iF>-YkZt%>pF)FGN(f^2^8R{ILv>7kOlsvTCby9pHa8nrODp=T$MR%<>lU z;(04AC$~=Y^pKP?=;qrkV@Kvg5poM5sPHaF|9!vwa)1c~4$UaQ2z39r^ylx}*VsEg zM-AdrA0Ey{QKbfe^u>c2A>&Sht(JMOlr^b$^u`IdGtr|$L%rljg^_?GtgnU-G!Q4j zG$MnJP1K=@B5e+dh=NdyKmI|n2vs2kfT;T5mMwCJyil{*UvoG=Nnm^WV zIoUQH+(#_Fe|?QPSmJfkO$y-LOt7n4aSReR!0$OcJnqypju;({!RFf_@0JV1S_SmDbc5<$|0c!Af4p;PA%Q48uGrs-5BsjwmY4bowuhU4v+J7RNB%`+qGJ& z8DN(PJsit|q^zJvNYl13w@^neQqviYu&yHZSth4^nRiG>mfHZKrP`fQM#M9S>A~u{ zcda=#mCE|4BR*+SF4BkYadcj5fo-KN!9Ukufmm@-hO)lH392sg^6Zj-yY#%d;+sRS zHd>Eiso#XW{$sS{3?<-xd;(vpJQDs7xLAC~NqbU#a>5sxD~AE%i6F?f7m zm>;L03GbH!rOz4avt5oeU2`sGW)w+jX14E%lh&Ok2pX4ODYK1ZVWDGp1H7+>b*VA7Ey{D|ml3;mRvFS&Aap~^J1HvTk z$}u)Vb2@=Gohnip_hXqbyiOIq%UkXtQiJ95X8669ZGZZ9^53a-v=!|GYiVW9nm?xX zevdrV`3T9V8<@PWDK%Zb?zac2QNbAP|H5jyp8Y%r-G3JnUJ4(4eSM{Cxm?l7KJ@|2 zsz5%AvZ}`*z*yvS#LOGa$N7vUT=Xt@ZY#C_<<5GF7kVoMWHSri1~tfj&ts>V@)$z zc;TB}EfWi7;JMR!S4U?N!u{(Bm)qC;vs+C2Heg67n?=UzBY$mjk~JdDY-56IW6x0o z7zN6!b2as|4*Paost|#qJ(>~?yiHPonq4=@%gcka(-|~? z;Uj%Ey7>bXxC0ih(YFddr^e>^Sy1XJ7(q8-t2R9mH~u)>w+|$B>*eZBK04yC3Bj7+ zCGx$C#^~I9Dzr_O+(d~s@xH@Zoxk3EJ>~js(5jK7cHH4xNWUvL(^va>7GQ}4K5?h zLh8tpJ1Q({kU%-Avm8O_pj!WN76P-XvCwHf@FmH<*oj1MI2TEJY<#)JFoMy~BxA)VcE_X)q<`JE7lwTy$|>Sk*-iMfcH z!Px17LN_#azyQIXkJppWr&*Rcycev`!h~vxX>ES3C>afTtx~023PGy@)@}sv`Joh38FhRkhTY_$)$Te9Qf|S4PQwLk{}#@fDt4(rT>^pqQ4f`VWXRBOZK9I0-V2+j zYdGSAa0O!oiahp3gTSo*8>2hiqyvoP?HLm|exX7YEa7i!w?a)$y1lR7zi$J#dBp~y zaJA3J;VC8~flKJQluXU4-CcDePz?I8=ri|Fo6WwA5M4T2m8bsAAJ3aD&9Qk|#fnq9 z`ow8<%JjsHj5)WckCvDXfe4yY2VIDd*gyqw$(d3U6&2%cfei@?KU0+0 z^3~BL&mN+Y(GOEo(<-ed!&5Q#>F9`;9TZX>4FFQWpup$bA2k=3cAfTT2>6&7ZYb}j z!(&2BJR@AQ{+lkwhLUqL&B5yTiHoRv23ktmtl7^KES7 z&$y4u=qIHLdAS)P-_XFz)edrP#?_HaVX{bog?PEc1#**h1S^&w$k*kZ$$|1^-*)2D z0R@_A?T7r0Ajq6Sds^J=BuJ)m#;pswg43v6F66O1`XcV13kw$s5Up}YAmYpwuUd}` zP!R_x@w&@(#h`*tbi?2w_BB*!A>ZDR7EMvZ8T}nKfj-oz#hPIhC=lz|w8zpH^P`3# zt-E!mCxDi~`(uo_x0*kJrQau3;?{whA;=?XV}Z+O4#3@|9KfOaVF#CrV=zNY)6XK8 zqjlqnkc}}hpT1?^4AwS!GdJ#&`13P=|5$9$nf}-nRS4rT|B4U~QFu;q!aFM%Er0yC ziUJeI-~a0HJEBb1|DS-1@IQRp7*4?Me}vrsgPZ$*FMMs!f5Y@_sRD*XBX49@yue7I52U(wNmq*6E((Rg) znd#Z-haI(?Z@v^+Yj~xscEZom_Bz1sLl$HovV_V5Hmp2dEarRspUvL?Svx|RnkE21 zlpgn3ENuVL0q%w8XAe98c*Vr_WYvNFm;bf_|E&Wzp@{xp3j-kkUFH9F{-4AD_rhx+ zc#1afA3|Y6A%1f*|2UP5rm0rpSF%=QDN{PQN<|`=*wAJea7xtg?0EmULa;UZ= zyc`TuRq7K)a|Ye_?CEI%hE{p1ptgm5NS^;rO#^Scg$rcN)&C(DQ%{f> zC)23HGnf;q&mUc~r%)-Mo!k|}#c)ZI;tl8jWRtqf;s2~5-~59`SkB0=yXJ_i@73P_ zg5l}mAqs^-l|}LFb#qe(l`Ed1N}X&xiL;nsJ*G(~WuoM)-31Ycn&;d&EVs<2_nm4< z*iqPN@BRD%O{4?DYx?~0HeF}7-Ro~OB)28#EK7{B?en>3U@v4Cfk?+jFLQ`gq+U?w5cG#_XG7MSu6qDn67 z#Q2T0=(cz{{|)3rBD0J9Q`j^uqBcjcTy5T$geOSb*xXdq_Ue1daK?$(^%->W`M4QP z_B@1u1^n{s-bO-Lks26TU#EfIa$OdYh#~y6nh>3n0;GL zgIyN;ONJ5jm?DTCedKv@K%qn~kLxZJ`At}4Y^qwBB7FuJ z5||Qo{z@w>sBCwNbTD`jh&_^raBF^w5F3RsR+K)TGI1hmIB&p1?Q?2WE*pdS&2+bG zs^q}nX^ih1o@V4#ns7NKv)91MAC(FvOUn$0@u5Yy`+lh4WBxCVCR@1dSIe`aKd)=^ zQADD#_}aV)ReB7)_jh+id^uFfYA#NX?Urz_6?)q_G-@^4ZPOdTIX#-qi>s^YPj2H! z+p=_JWwywE#-_vrEspQplH0y09;ip$RY9kA-j~6ovkBb9sX&8EX-4s1Qk<`6QH{*nN&=;@M z&i=;)W7cM8~62K%h3Rb(!DiPWA%G&k}Y8a(N00L!W4O06_tG z_>u?u$A5^Jv1bz*clzHP@tu5Grqg`Ns1#XBFZmTU=~L~IA|3v)QjlORjE`f zq9!KbYSZKQiQ*a(mEm6g6t!{kvD{M%&4So>fmWXzSD9oJ@_Gerb-2GHW^)<6*a>i$ zS`sbkK2XfM!#7Jb!B~2lzg%npqLXmaR0-sH)FdRz=!B2Eo|>nL zKas{&ELV@-x#Z=OutN6sQMc&yn7>Kr3Xvm=^9gm4-ekAuTTY10%u2hFDezD=pyV z+n7uS$T5GIm`cL{VsBZGFrD6q1}|9gfq`?#wo9fAtF7965S4(B5oz@8cs_a65pxOH zT$4pcj#sEqt-6Z|1idfQ37tYs?;#;0LrpAj_(#lJ}p7Hqh~_Oh7TKO4inw@=;8&k!X zl(R5UP%6?kW;qreybewBVK62SJ!|dXJ3V##rPg^%rh^=M0=_Ff3ow^?_x0DD zM3f@hB8$PY&=9P-V0*L2c}%yqwJG{TaXZ+%(m8JZ(Nq#3{?1Ct^UE4X(HVj>svg+D#YkoBC3Os&2u zjeN1)tRHkCNoDcg{flDuhDp^KJ@q3eE)5MLikoL}@DmF0m$~^1<4hXmwB1JD7q!t& z{NrY8URs*E=(4RW%Q0;kkYP%-B_=AWTc*}juR$c3pyZ329)#6!%PmQYD4a-qNercv zX~%~AyHX|p4hKdxquZa5zRGer$`(IiA#e`P0x;Ic zgWCM`Y9XsYx!qPHRAJ~@Pf2PQ^zyw{$=z^CA5$q5viWR>Y|I@oFV{M24jyq^)R=YP z0Zlgxr;ZsmgoF%3xWOU8rEzx$U?sd+4Gs6CFZtkc0ur&`%ZtI>oF20Qt2$R(o`Ul= z(kk7$_%SqdF+5}*uXl^Azkl0OVOD~K1A#`HNf%2eN*9;vE1Rm0 zgGy23;p4Z@TFf#YEn;J1L+zwS$lMDdHE{M8ZXPELdIw??eSLj?)Ou>GF$Lz_yoSjt zeD_H?S`c}VQ+uamf-yGoK$DdG}>N7Mm zlLi_Z`wxIwW7zn6Qp=SVrjmop3MWUo)%80c zeTdSrHX%cWVEv!RPLB=`DI z(}_}x;TlZXzOD5M#6wjt5yyw2X<(_2Id!VjszEN>HIq@3w-GAid-40u?FfA~7;)Yd zIcsWWOaSw_rfwp0@XhG#`Nltm@6&o6);9Uz;r{V+kJ!VJj3#b_57)smD^ZWC%NQV3 zYIChx*RoaQ0Uo?J8l+f*DNEIV4t`yu0#e1@k;_jQZO`9HbBAu_P zTedo^n1_OumiOcS{kWmrjjl2lq$rU(ndS*9y2rdj$U%CHTqgan<#PR@FM;W?0ZF|0 zayqwv&5?HEiPih4gFVjzy-95I5x42ZW#ZrH)oYX8-J_Do$|$&%9SThN05PTE{@`f5 z_0&AEST&vo+I#t(F}5vkK;)O5CC+RLX*gt&TK}tNF_B_}T}Ix>bd7gnqO$xi0KCWj z-cJ+$IDdi1PYLSe&foz1UQ>FV*(=XtrE&^dz2=_%f!WW$OHkcjUkAt{kTt)}@bKP; z!=7$k9>X94>kgxr>e7 z8eC>?ejWoAK+%J;cve4}EEg#?D~w^r?lGq_ogpJs`vZ2n?p}g|(uNRld0m~IxhPR! zTrXDIe)4eE2<@wtiuCuLOz@xRjwDAcy|8Pwyc>9)gkm~#soCM)-5|i;ju>e4{UDAX}5fFWNvl4XIy_&LO|@%S+PNfgPZjjo7`_x>=d37(WcBx z{7~K#DeDQOmIwa^1#?9UpAp95DQs1k<4#IJW3zquH~QPCqltPlmSF9ki{YBV zzc(cw?^43q`aT6exu2wOeMU#C)MYY17!ET*Ykxgy%$V!L_7IlqBB8TbnHQ*frb2=q zoAD!Vd8s3S;IQ5r+n#QzUXyMO2a4Q++n#akLBMlNL+-tqTfG2Lhhlu5PM%XBBg?E7 z?nFuP@;Hy0$zi|jy+GrWNY=81z?i^Mr0r-URN)$y6;lg-Z4n%t)ELMoggt7R;aaZL zZaHI`nMtr84-E`KcKCkR(Js~j@6QmR-WjJY1D5j1-<0W|6|FsEbs&BWm3-Qb7){An z^Y#{o3E*-bwK#v(UX8FSyJl$jm(cU++Zzab(%fugPG|QB5j3tXwcxiudfz=@7pL34 z)QBKD*2arH6Y`Sw^^;6#TJ7rB%?mDFq67syo zY0*OlAu?pWVO*UB(HR{Pm4jwsjnfts&OPIM&j>O$1oIos>$pmc;*~UUczY3ALT&SW z!pGw@-7p?2iU1PqE`AWsP>2U7+G`)|>@oL+Ozwtm{F4NvrF-S;?J6yopx^sf*TjJv!0la4Mf%@$sr zJyAEG7A!|DkT>R%2p-^;+s)3g(&*z2(PI|FPhBf`phQZgTFdgTX;bS>X1*2x#Iag8 z{SPTJ84M1Qc61pM{_ZL9s97V5*2_)bFs5Hd`{D>{*Bi~dnCIrOu{ZVgq^RZwiprEp z@g)mdg&s}OyU56if4NF(S5@~e?rA!}G{3DG7^Xw`-r=pAt=@+sUw5@=YuOe4QhB%w z|M@+Sq404o6f?E^N9;H|II2cJ%v_Cb;aipRS6E>o4uJkTLNduUq+thg@{OnMZF4Dw z2S{_};x|9JA?f$QZLJ~7KF$X`*S5$J36{#Xl3kpI{ z@H3DT33X58WW%FevRW|5l8p%)cC0cX^;8AeuLA4n>^w70I8qM=MQ0r*$31<8)jO%QU5b_W3YFtYd4v zayfqhcgZlm)0}CT3f1RI)5_>wfTl&LAIke84(0^njYxQb32CAeaih?r$wzX(DDRwd zT6by+jC$ZZKO#Dv|NKdr$!0ND^zC-WWygHKeEv25Vi?PDNWDnf{)~gvWczs}!W&>7 zhB*LA#m$p;F_rDfXe&3}(Bw86aqcwS_OWvOBfIFoi7)QKa1iQM3OMX#rIctp-5IBN z5)u-+SG1F@ zl7j(4lTs{uB9w`=#BVUp z$*_F>s1*mQgasXo!h2@S_ElT*mkSOaZLq%5kB5tF@ct#arT1V}wQe zbK@col#iP8hhufFeqeFZZZ0nmOYuO?zBUYhL|`(M%yLhXWfhQfU+Co5qg$1obZekw zbGnb z;RreGm?qMTknxR6T zRDqRGyTLt4+hdK6j!x`~A(H-dSy!j$0@#hQb~<8?-DFI*XU^l*;wPlm&eIRdl#}q! zM{H=%(Ps2Q1g?e*yGt|n&3yzX69>`}SVHgFi zr6~9Sq*KH5uY<$Im|BI}D z46me*-UZ>h&RBSj8 ziD<>D@n`kjp&YVSvw1(ayL+qpy->gCKBYnjk}b^~fST`4WQ1Y7KqN^)UOR9?t+<{o zRA_ak9h_7G2@kw@>>*mf(u$2tw9>ql@xkW)*krrzevv&$2BYnNeS1$o*R|~L`^(L4 z>-Txw?KD12zvheC0?zWffP6KUw6Mip%D3xm_9IovjtxFY>xyNelQP>B+e30}RDdpqTBv=@$#?l@1=b z^~AMgOb$wm$@12i%h5y@zrl`bSuns8Y_6{G=j+M-R~7ZjWPrhaT;I5OIjK48+)rO# zWd6pz8*2yD;3;KgmL0lSjDjHHY%W(UC<3Iyc(n*VdXWn{>-eQ2Dv4!K%=?VY3aV0tiY zV&s(@eaDb7%MFAeMvK5HE-lT>Cd6k$jI%zFmX_uMo=;p8qodOMuJiS(Pckmc14Bb0 z>Q$QhjXoc@ZC7a%e69A|9dDwk`lv3>G;3|KxpxJ*k5{Ye132M^w$>ZL<%%s=EAw-8 z3~c7p_FZ=USRr8@9md82p^2-K zqt6i$;L4SF4B6z|h|syy>$=rJj#2^(f-i`9P*7nS=Q{S)u0zGkNk5%kW~wsf%LD$c z*&|57lt5Wi&Ih!iIoFpaRl_NmU>qT?7kjNcQhY?K;J!xpXU+$cCq*+8;_F9G^gyn>j$3b_vYs3Xhc zq+~8RT5xn;lJw(%vAi#>3>0#olI@HN2G3|Vv*CME{294VaQkOVei&AKvbYC^r9fnz zl6B-K#^FUmOA^;yRUVg@$P)$p(YY{WuuKZQV-~Sf?Ro&e3kY>WBo?B#R+sjot9&N= z8jpveU||k>dV2Z_TRH9GJ2X^2XSELFIOv#kJR8N706L(I7%1U$x3u&?E0@|zN%eWy zxopk`aE_mtSXf)jY35KqJT*#6=?d36p|%-lGkmP9356TCXPPomFS=thbnxKTCn^-n z@4pU@nSc4%je`t8Mau7-`g^(u**h>L&g6q5oxh0TdMQ}R>w=)<3I#DiW?muG4)3G= z5~lf)gs8;BlOJ7PJ|Gmx3_>N=XiNf6oG;^9z|R;r8crrcvO0p*i0OF{((HML)Hgm4 zektkGUp%f}!wf~tRwDmSLtQ6@5aRMW8cdQz#SSF&lo|NqMO-Ho^gb6PsH!aK3izFd zS4Q6LI~|N==o*~43EmfH@}-XcJ1+g~DnxkV`l(cVd&Xri?_^KNy|=t)5VZRK>s6}9 zzjqE3D28mlnaQz#3$p4ZBVb3iNQ#C1W%h9Luz!8Q`}CP@made))@+@`^T&Re_m{67 zK)O?_k>R3OzHoAYaZ?swFC9r`E7UKAc0(wZ#>Z)a220+EIH$!P`hHd=mifcEA&&h+ z*Gs1Tmmv!->?IW7A`|}p{dy)p591Ii`*3D|Y zjR7QyXihm#vyDD`id=jIfRU&5aSjI8Q<$tOnOjFKwm%JyYv>T*mn~mBI=J&nHGjRY zn%D;;_;m?QBS@6RJ<%^#mna9i_Q|&LSs&Z=GQ1<%M$9* zhoUlF6!&)W>e9s^c7j#Q8)*z8YxFyIw@SBn24n1eBNTH!OQr3;Tir%Ex$Fd3?G3Or zr8}vj^>vgK7k`^z>aCz>KHb~y}XUE3;Y_YVqt{6<<@E^Vi1S8~WciAmEiY#@% zgY?iuAeO;2S}agO$HahRmm8+3iem=OLspq2&=7VUqcw9s8 zjKg}iUP)2%y>d3bQJ(6{T?jMwf&dg~t~ zOH~On(oJ1GcxbHJnLC@OVIbqc6W~B}e@YXe)q5cLOF9VS(fxI`uDqMS({Srlch}3MjvqgDHwFM_?ZVT; z4XR-yuKaJ0Cjc_TczV3OFJ${_wpq94+4SK7Ouk>Ug{7IRHXs92q=RGXh;PrzChm^Q=C+w z>jiAgf>{V5h$jA=K;_%)>S#QDzB+0fIXA+~ikDDhNxLq!i0A=pQwljXUw4^5I7#7_ zCPMb_Y?zqxg)(1XT&eGZ@c{Ny{xuwX3{1&JOV-{jv;QIq5|%otSZrb+_U`OLMQ{AM zT4DnE#~fR)9lbMQGoWj|RGBo^eTPv)BbAWSr!j-$-;2rbl&Y2IZ8piXWE$Ib0PJ{; z&b#tG7TEgxch_QrN+bX!UcIi|_^K6X(PAEOIW>fQMuCFpO21X1n$2uHNdo@>9i*NV zmG#?NzSeja`y(iG$HSZz`5#kLv@3jaQ^tk<;f5tuDwJ474$WDfl^*r#0uU}*M2G_j zex8enqUppR)BSpzAg6zfqNkF>VNB1;UCyObG=d<9)ie`Ag9!?Y1>Fu#5l4Zjs<88w zuGS9!=~{SPC}cMA{BML5dC@FW(%+@X!>BJ~z3#uEfCG_DzHcKtK(EB6&+oT$a&=_0 z$U(RJlC@;J()qlzTj=xQ<3j*M4E-#7uolp41yZ_lQozthCngZAKIP}PWAQg!Q%$bi zkcc6KNo|V2R4T%Kf&*TKQYC*+1s7wdD^$iCx~qo-KI@T_Yy5l7Tkzmu#wUn%<2P%QlZ*N0 z*%ImUxLoILb`QTd4~ycKAECV-&plVW!9!z$SZ0U>xfnsJq_V>Rt!+I{>JBtA(IyQ> z(G8d{2T*`If_{FM0?t@UNqntr`V9TW;{q3~85kY1j4g@rRv@tOxwgn~I50#NG5zwT6lN7**fur8#??;ZwSzHI4p zpH!)Z24RSogDp7`s^&G@byhlY{C@03+CJP&m*|A3Z9Ue>W4J?M*gh3N##8>El|Lw) zt3Cno3Uact>r)beJ-+)(HKc>%JsW=_e14Mn=4^dHwIajNqN~))=sE4gZR-a@EfHmw zdtiClT5?SIF=%lS2tp?{B$iI`@R_b$u|jL~6v6#C#ecK9uKfWAcemB)DhZ44{_sOy zGDS*J&<+%Hy-(W3@c4#VpU+B9X;F)`+?+mMOB({O^+z-3h;nsT&ca)WJ*5~q>6x;u z4-5sl(l5r{_99jwWn9|Vld!0R>9xV&>+0OcOZ#{CFNQ@&f|~C~|~!?PhV+$jn9WyvW>w|O4)oecM))SH0nhf6{Y zqi1m07(2&ezT?+!cOC#dhQwq^7rmV1_pl{DKxFA*greAf=TKAerwlnGmDBNdVl1=O z&aaQsl@Du7jrL%nF1FTur$?^~&feYK`E2rXbwMdy--yWIyx=q}EH)>_dact|(c|&P z0S`9wh(%sCC7#1ZE!*SuIloGca@p>?>lOcwfTfOEwI@;b3%dRw=ZsQ|f2dQdYo)nW z$wB~j677$gj6j7m^Nx3r$=0Tg`TqUKrAeFZeviN3eiwoyVFH)r48EY>E~`rUB8M+7 z`#Bs8NYh_AN5`Ans3gmJh8|>idCe5*)?T{OO0^NTVZP7+*xw7t?V|T7-lFL0EyEb% zo6Np}AtGQ@wr^a^VFhDg$UM^&Ai2)h+0AqOI*hSQNxXF^vsKs$CZFK>x?9?(Rh)d7 z*GguQ3vzfi#Kf@4j}X$~GC1+!6d6D3-rVdNR?BVdRfw5$GDIxEgtcr;y{|3~UVM)1 zx|~?XR#v_jefYa_DGbQOZpDQgaUm%C>l1v(5pC_HCxMoJ`=}gN<4=pK78*)VmYHWF zuD9p)+1qh4kVD zo+$ur!4o4sU<9RkQminsP#~n$%J=4uKEl(lXx?%5MxwHG|36|s8`O83^Qfd zaFHBk?EYyhnjkWNyQa4NT78G^rO$%EB29^U9N8c`8WSW0K0TfphlRDKm+*s&PhXr% zu&?Gjh9xWZ(JAGxx&V~m;%AThMvOW`riNCJ`QUsNOKo2SqO^?_!83Os8QIkrtA}A} zvLDmu#33$-S}f782SFyZ-_~*JSvsK7{OIJalD}q!9?yB7H`5#wW3|_6W@hRVi%1W2 zbbN`h{&MGr>48ns31Lf{ddHASvh#?v9ipd^&Zjp#^*uX-Fl$vP!z290o%uV-054Sr z2cj5;AAjs(Q?6=h9eQB|7xR8wPcnC{Pl=6(YEC}|s9|>E6A}V}>aAQMTl;um7Mw3` zX#Wo0Uud1Q{86+6>6jgXURYGH6EebcXGz`$-lAmSS_Pf-^yg|5CA5d3^;Wwm8vN)i zJPx|eb$G0z6t~YiiZC=`+Tlb-d!a#Y&fraB1KVc&EYJ1GT;~fl8JS62@u86SSMb1q zGF8kuc3H7>^GaZtI+=9T`+Lb@{zpevLSo|4*um1xz*~)Ihu$OmVP0;HW?_z?!D6|c=UWB%{frd%UQ;KqI8SGM{#~+GXQpTwr!G=I}m~5aR5%!r>nWF`}jd(YpuOK@P2 zs?JO6k5685$V56f$MevEGelFzhyQ1nN%e>Cz+^lM&UMB&?Dknia_`X$}4HjB>K z$b^{9r`F@Z)=;+F?Z!_hl*rVBL1%3UV!pmCk*VH0oS*cJoXb+|-~=ys9;SCyX(?Hd zna66@gb6fG*yVw#j97*HudbvfbXcV5*g35DYA_f1+S0R{yX(x@>Oo$AwakI!grd?zDm7|po6oWZT=&3dG{djM z!!_D{`~9z{w`ILuk3hoTVKPMMlLyZC_TG0Mqaz2l^mHZw72x(X_GYJ>FNmd5G%G8s z6DS}(_j51lXfX=%l8my%eX?1uS;g&9wKVD4>hkT>Yj=^(?5V_EtQ;$q!)?X8q}%Fp zW}B@ryUS5J@@vc1|8IW+}5DM#5Hj8_4Ks$esBTkaDAgG)-Tw z_WK=NHnC<+$_}MwHn6QRcH|i0@opj2@xbUjBCr(k0V#sbEciq-Te*2%nex@k zDT&8v+ASa5tH7_yl*a12tAznH3HFPv?y=*Zi#=GNQrmhXq0HYc8?cv%eiy+9cqyW`ep+>LSyP9Hp zt#DcqGcVV%N{NJeYt2B^3x5BJF>3p(Tfz^xk3Js3{Oj&dgQnX$bb-hh)lED31v!_$ zl|MPqe4g~Nsa&mAo>D}dMQ$P>E=-$Lr1`+)+h|9N0_q^pIJLu-{7d?#5thJbhwM7> zS5p{uJI~iUwdT|K{vZdwy>H*!xX2+mxj9KFD8y_KztMs;WOl*IR#U13Nb{<)XsXKn zP>+`5`VL+6hVor4wg$1hoE_SuEI}kRrvVhHFvomXr z^71b=ibc+jn{YbK!!wE){S*CqA4c0c>rhx}1cI0LCw8!3T}VN4@VJ8iGf}*k2h=&N zhkRj^kZD66Fv-6|tD5pO5VPtriReLi%p}o@5PTh-7d5gS&^u&n_eu(;R|fpE3_?{B zTU)37F<{Z0Av6RhjrMe*<-`!G~O9F@wpHT)(m&V#c+vz}bY)s<6U>qsw0T0K>6bQr-M=3+b z!i=Reqcb{d+B7p4>84Cu1NC27lN8dDnboyA9f9mCP!K7LF;`&(4s=t~E)+7rN|-DY zp{D{FkoH?-)apMUMSM0Wi~_2x4vaC*^-4xt6HNc8!+-#`p}ttjnS7ZdXdJb)AWY1I8aErdh4WJMII`Z3Rpx)v_y!B zQGe_L-Vq=&z5^Vk+dq%d+nBkIUNTTdCoe-zvaN9qxBYxh&qOvF0;DJ>&wyC^y6ne@<`9-NbhS(E-B*ii|(~0l!#0eu3Km))OY?!87`dN+qbSCqyzi$`% zKi`Kv2q_9$Tcp2cc2^aBB9ut6II}+_=E%%oz>F2S#J*5j5-8eD9>3_xp$HJUHq@0m z)|yH@Vpn2G+;kRFHzT5yGHTnXBt_cam;V18C|r(~qI|(+=*i>>J4$<=kO?It4;waK zwAi+snPo!(U2ocQFAd>>IbT-hSg&Ks1mlq~_#eAP7*{9;h@5$8N;0qU1Bf{$nW^AG z!2Gi6Pp`eBDX5W{vQ9wSq#4+t;%&MwhZLc0FDUGog@_8ZjBYxWosp$X?G6#lh#8)L zLi2X3i<~SUosVA(rd*FhlAg7swg7WkOzdco$eo!^uq}BA0k4;9o%7?D?pOT%y3kDr zfzTO{(wM$!uD^v?X0P4y_0sdD|BQEu4HxnaZ>ic_0%hQVvs$|-O|>;rvX>ni8X7~l z^|9I{>?z00IeO-=NenQ-7hlSO>)#l;$;r^}d%Z)qsCkK6S_TeF{NA(2cg7M+1DvKS zhNdgrCdI+MHY_3jZ=wCpKxtnKKwh1PHy%%Ugo$rbiAaL~+`80lyNPzs&(-kUb{E?1 zcV9Ta8l6Tnu6UbL*G@NycpYWFn;doq2JaV?i?}t?Vdv)Y4W|s-0hw}W_uo)aQo5Q zm6ENtve?tVYzBRu!hCTdKY~+vso@XUI&;aceX)AqB8=yAR@qAN3Hj>2OiG;HFSn=w zBHHmgN6-0O57n|-m8QVh+SNvZ>F@p+pV1-sMY+J-be>RPkR7K6l>R-1r(ujMd)bsp z`e8P0WNq_YSE9jOG77{GB|_%QCic$XBn&Ir%H>Kn?c@nC-?Rh=Mp|~6_>Mq5W@_5s zo;o~`1AeF%56fbqqM*x(*4e+4M)&uRcWUu(okHrf zdA<7@j5(iEYge12n|`&esTy27z6lvASo{`;YF~gdfpv*2l}@S9Ui$(TG4feis8P*H z#=4^2B3uw*vq{oOC#CT!xZ`w7=)_QoM0P=hEc)g?z*bxXCUFf^n+Kx=I5{uTnct?@&~j5! zN>&XYv{dbt;ITzT|0e3{uJOPEc3>9X2x}+8niG+U1YI4R?#7iW7uA0) z+v$ghcJc9dm@zzy8~gidbhu5D)jQshI2a(7x;VN$X0q9%zMS_QHStk#Q$TvYVMj(p zMJ@S5o0rWH5rMsbD1?Pc>PALHOb(1{2`BFAwR=BcMdfn;%$=Q`1%<`un`-3d;^v05 zU2Qk%;phniROAJ|;Gqem`DG3Ig8U)AJP;o)ich{fAB!PR=(gt{*ZF=DF@#^hEzL3w z8eA%WMuLV;wo(PEzyWQ{E}0Usr5@_4#ioIUbqPvasYHRGTn-zYikX+Au&jF}1c(RY z;(@jFT-qFl-pg-$!P5xUzz7bamLoeIR~3Ohq+o6Dn&CvJkjLG zb|%-A6M2zAo#iU3x(UW5+xv2Uv5OyIV15JV5aRfn9A&Z53s+~zXnweRU|ABNXi{8m zDX6>fIQ(a1q^;dL@QK_g+*ThOE75RKg?V zT!AEKA9;C&HQ8fDg!#Cn%%EuZ{P~bbl(&^Y6-$5;uuxGxKR!PGca>}>gky_s;cjAb zQob?6$lJRw4BjFZ-JwSX`pNE#gpHz|(2}>a>Hc1xX9n``F1nuH*5{xo&~&mB@4xG-!j|H;RWyzPSUTU;tvkRm= z`B)c}=82;`ELcEclR1mYl5r!FY1Lpe7rPtc666K8^pAmIn9gHKjLnQTTSEQ{x%>v( zE3N>oVu@hQt4NthX3sR8jGHKBZN0c`zmi@|6@~Y}&B1XI{Fqa4#ruSlp}Hq0QLEqe zp)+q!`vb@TJey$ENMh2E_=f$Ek4nv#(L4bjiNW}=eV2ME!MLxwkeGo_q%YSx!N@#u zCG1q%iNo}Z*J^A6TC-pfp+dR;b&#w2>j)#|&qFz&-TZy6So{0trPC5I@&1;UYp>SP zmfkbBa9F|m@MKq68J;lzWK@}A3;?e7@%fIDP(G}PH=al>#Tx)5`!mJ-vWnCa*R^>~ z^>zH~vO^8P+m_+@fc$_C$Mfqg(v^jf;w#`tH#p6FVW#}1s%Ug+4zG^Z*E06EW+(mX zYh#fO-nv_gsKg)gPD&f{94SIVLkAx(=7TVq`MmE7h?S(;=Z{Gt6Vk{~88g9tJE%Yr1-LK5MS`=WU{irnv= zmK|`*oG)TvGir18t-R#DGWGWWPS0hei*0UvHraToU?2>WC**>^uy4y{W;NS>yRC;( zP4UiZwqzd($my7T{9Oz`u6-{oZ1MFtKC|8U*JiFM<8X;Aaa8^EP;|$BY~f>{O1^e{ zc?uKcj=RfwJv3JCLF5baZWt2%?!J7FwR3gGVm~-IeC;T!Ja+-WWv@mI?sw1RC_ZB{K5%uF%sg-` zC4qGISFtRyu@!3`e=BE;9u(`e&|SNjs+JWlA|1qjrRkSiTZgVg;N4O$wpJ15uY_fZcs3s&`8bBl+5f3Ph|0Ha50erClQ3 z3#t&5GK)^X?SMKsiKaw_-=MCR7qLo!w!Fm1B!etoeinkTC9^QLl5~9evZft<2{-D+ zgY3CHxK@+tAoA+;)INbJ&r@c~#jx>puRc3~C6(N?f4dpB7j-}f=-!#}RC}3*uB+(E zFG5)KprZOaufl7RN7MLcw$YCd7lNj7c5k;Ksw9UC`AXCQv(ixB zFTvsD6?ghwUWJZgada3fI@Hnfn}rge-OM_TyLU2~cixlQEw#nY);J%_L>&w#wQNUI zsjg0AeKJ&%!}9cyV3;YFjr1g7g?Ge&KqPQtA+6h^fUxz~>qJ}bL+lreQ&nSZ>oRpG z0luz<3!s29Tf%|A&gyqvx7BIYwXU|8BGEtrDbVoo_GZ64CylSWsWbR`7w7>U@v5-2 z6CF)6EDxA5J6Yt~ts6@gUlGv2S{k$B{gJpAY)X3pLpeFV@=D_3BM)gwUcpdTD0L5H zYrPB%3|!8nTRmQEHH4hJTTFju=Gh+UZ+6q5592FJP3)E0^1AStCT#ltyP{y;?|&j> z^Sxyg$gO3O04A`tH`c~SICHACc{w=y`SnbB@Y+lbjiD|ho%n@v*4u9IW_%&ixT*sBGOYt6mFT!9zR^gVRF+i9-YhBP+E|N9sDlS!rW z2QJF>%}uD|?Z)xT$^w_yKO*_-7A0yIt1rFOT-RaFpBt});P940C%@c&Y8R={RCD~Z zu>z5!6~8;0dbYf{Isq^_+iTr#zK@Be(}ensc7@-WdMqNrV4azDS=ZO8C5f}ldyGKx z^SLE}p68HH0XIid>Fd;BTr&*x1bUA|jL0(WjAsWKqF*>77^FFCB3eYNLqYU|q>iAf zM^M9%B>$;&i*1&l&dh=2ok`#GuaStI?kO*dOJ(H)@ue9=yQ(rop763_h!>pEnK3 zk((R3c>*G2k3+*$1NLimr*RP%`@;W46grqJ%ye51D`veQ^%j<0>NQzy7DTWpfep>A ztn%;3(lc~^$0yo5Jgis+@^F0ot{xW!qxfS>!#c*26g65&{xq} zXfD5)u~w;KOk+2?U&N%-*oL~Ox$KuzRF@PVuCm`cT?gIT(iixXlRS=%*?8y<(&tYF z^*z%Hgc#<7oo(qAZ`T}4;@Q9 z+lAOtriMNTH8E8w+^H5(>QIyF8oI&*d*~SjH#&GJ>EYo0hiA=NpJBK2)92C~GGX=b z!PF*nVL-ZdFE~-9UgK-lCNF2SX~ll4;HP$=pfx_8a=Gyf{%1FThx>(?wZC>C04cJp zCRxb-h9|%zAgElLB>1^iSJCk8zZ1u4wcd)l#ip9F6A%vc8g|7ypmoP4p9dK%&qUE)EIAom278$*q5|I9Tt9p551Pj-vg_@eJ z%*piNl&Sx`6Jbj;d`AO6<+XR`H;iV$YT!y>4I%n2gGi8n(-;$t! zOT`M}f|n3uWQ!qJ62%J83?r5+cdb>U4Mqy$u~~~TWJ7UzZ4|i=bxz=Nl+}WYK+g<( z;&7)dMvIE}51m~C3&vLZ{3kH@lz+(+$RTa6$))gF1&{y@SXgNu`3kyB8%Be+!`|hL zYF0KjrVOv?%G4=*IVq|4sry|P}tO_ZJ5#S)BD)?%2= zH5v|3HK9$oFa=LjA`p=sEk0ILPBM?#b1!9z#8YXb8l7;qYVQ>Pc zvIRD1`1$qre>?yH1!SJq%$kJ){^}EG6HIiFSUhP+WwlmEJg2g;u`$|OyN`R(($C-{ zUmoq-ELrSHo5+2Xx`FlfP2^mI?t&}7ufF0fNGvABS1elY;Gq};MXK_v)nC^oGSao4 zA2&xU_CUIytSxU6z+3d#P2IVltckk%pDS+M*Amrwc!qR;vV0n#%xvJHKOacv{36w}Uf@elV7N zl0@MMI~zK=UV;SyQm)<%*Y5JEMxsabbG@6FlAbTnJnCj9*Y90hE>+F`u~S^~11>qM z)(;6P5^#XNVbcAenOqzcFJSTH{(gS3bPyJckRfvulij|3y4Izr-0lo@fAul6JbVPQ zl_E6|=!K{=;`R74s`0e(tihN{28iNC4GeOH=pP~nMaB$=VfQ5{z$`C^j{dZ_6T9aM zIW1;9P$0;A$Y(TXJN#RTh;08=_4-SCxAvtbFSlJpP|y46GAKN!oJcn{E)J)9(c3zS z(cojh!~M#$-92<2>}nWxOOaLqBceI1m`)v&^(no`s(y2BZtiyAr)@l5EwwDoptUnJ z0eGR)!J?w8$SIpG>yb8b+Y&6x_1}>FTEpCgyz7_{qeIA^RplNv!k$;;d>xzqxK`Zu z2VC6}ZYUEkR$~^XYS(=WaMF5mVYyJZ$%P*O1$%@7$Nfb?>Dz`iy_lYvpc(~$*yY`1n4&ij;2oJe<Y-2?^v)lw5wM~A9r7QKI@a_rw%0pGlAVA5A-0PnZY4GiQU`ye!yj5AFZL8Jhp8LKw zAu>%8+=e^u=)>UnGN$c&xR}0AP-zSS4t#=X;1b5|#dJ?po>@2ga5T^TS>4~u-nhB? zFtkf-z?=k|w8v8tc>}>70$RjcT+y0J)#&=yA^%%w8$>hTOJ;$1(Cg0Y%{C7EPPaIV z>cGhTiuM4#aL}wO~>-gJ+QVr%w=8Am6XuG47|_9X@Nh{xrIq`ObBK}UeW4=UyHNfRazR*&-g z+v6!T7jSaDRmhxmQY5p;1iUW(XAz!_rHvU2-yKcAvNnGq?Rb(e0vVrKDfC$;W6l}( z-hyry8${@>H3zbTW7G;u1a>1NWSmvzzLs1AY$gvB5T1tva+F2K2Go)BCjsU%f zUY?+@=fkV~1ppa(e0{FhEdzFW9rbEmr^Ji9$t<4oQv))|+6-o^K4Q)zgEj|T3+xpZ z%aLnCAVJ3$9*c=iquHr=08l>ZBG>5r@{GOI(}d=c2<`rRBFBkHz*zulNd~S_rK^Y0 z6uS@r{u=7<*5)!BsKu(fjjbv;4?_vZhZF#c4pa?YlCBD^jM=0LxdMWg<1GX}dj$e5 zZ0c0Lx%Kfxj_31I$E6B0irI(L25P{p7>VG%H*O%4E|p_F5eKgw;qne{>ZqfweRV*( zw*V7144<-5ojjNpKRbL@X%Vaf^ER#JL4#f`G2{5Mq>jM&k4nCFs~7PiTqw+2hz&cq zJQ6KOI(peoR*X_i*&@9<&4M*T`(XaP2B%u3l8@n7RqEitG&-T(T0#R~QH?n1)+S&l zmQbgk=n6DqIv&=ey}0*`m4IQu_5Y7*i#Z<-HWPvmIKg5W#2fGBcY|~guA&KE$;*~( zZsnCfa2+Joz-nnUe)^;1d984{+e`n)mZwmMX{SZj>2;sks#ay@7T|2*a@s;k%HoOoM z8PRy+$WinqZ1MhAh@CfpwMc}j(y`BeMr_gw9?3k1%}9b|R8zx$S7%um^y+~uo<#)cU@svAAgY&GYrI3o+z)%JJlKe`R||4y5`|B~gaf&ZTq`u+bLbp3x$zW)DB z$^QQ)@%^7!ePhC3=>A=mAoly3eZP#;xl5G#ppOML~YA)vV(MRO23NuimB;|J`2 zP5Qh1#`p&M-$p_V@&Di2&`c4T{^!sC>*arYGuZ!sKK%cA`CtF!zkdX5FfjkGz5e&l z{NH!`-}d_7b`vH9V=5(lzb6xs$0qwnPoBKPtQ6`ejTVdn7yRtqZx6>UB0_DE3`fGK zq!5iolEJyqqaOLY-?P$_PmP%dR=G|O#TR{VA7j`-wlg|W0UBEG3bLaHPL#C5l+yE#6U`LyJ~gjSb^?5eWXb1BWr}a*+nmf2ZeO4H zohAliD%EQU4lfTP++BZ~J`JSdthQWaT0EZ5K5zn;NZYq!S8W%SO5qSS9&oK0bWB?Q zzj+7^KTmHKwrLXY%9t`x=S_s@fA{wA4xQEfb<>ejE_#M_0`nHwmOekFbZ~Y>G^G#< z7weu0rBMlN!#m@$`!v6JodN$V93cE>3jz$3cIQaLcJfs|U(*4CBt$_bVIYN3&*`f5 zM`e{Z5RGq=jf6uPi|`8~C199|3mbbN%*_}Z6g2|F;hVh7oG+ipbJ_7Hco;SmB-GBP zeLnKnI6Y>k@GE~8{G?GO!&AN**!sLE zYos+aSmBdt=|pJLEb_RU#5i4NFuj0Q~`m#Uht+e#o%FuY=smGlqC%(cEIxiS(yCP!Gi= zS%=#V52LjDtL=KxOlA=w7$E0W-};lledI&@dwBo<_~KF5-c1(5ntAADc=kkL!3bS^z zyFZ4A(L!j^qH!8yec!soWGDxs^cbfI=UO=Pc`5yejEu;lxX68NEiysDeEz)YKWBub zroXcw`J@o5!9R(16te}+7TtU6$m+DxYu#@w@G#&gXtj&3ADv5M?p@v7C^;mB;^6B! zyn%3a*GzQ`1f<(ytH%V;?$G|-30xl2-s1O~Hgbpo5_*XEHtgX#wki=+Na!RO^x*Ec&!_~#ejY4z^`SEToSt0v$B#;E1ZKZk$`)gj1VJ_CTZ z#M8J4`fsv&<)X1Xr7lNbbJjHh>eLDYd($G{ES(9W(TkJWk7jH2Y1C=GYoEfu{X-L= zx61v5w*j!j9qvR5!M55T&gBUdBVm#-QW8HgNvfyfU|83!3J~Fr1zHO z!3vJY_V#x6hFZUTR2cq|K3Pbv8^8y}VpTG!3PY2!N^LB~CS+!hV zKSz-qT){o`nVG*IrC^|1Nt@F{<0TTY+3Zv6F_lW9}bPo$tAaC3w2?&lyh^1#`++7&jIkqQ11W6K$xg;u^sR4Mt_ zPl*`L!PSWsQ#$X#yc)8S54#KUEm|>eOtxr453GCZ$5l^}@hT|OUl}RVgSgC{)6>Aj zzNI@s@AqJU=-Lb!K3bO(xNaRdf=?n?Yu*CJQG}%u)d2%$4^LI(#(fFsKLZ=pz#IOh zVw$Bg7nXJx*@TQxd0!&l>T(L}nKZsxC6AE0&sh`~^$KskZ zuqXstFWbIXMn=NZfhA;Rd0+gi2S*3m#A+8cEzsS9-^o=?k^KjCq(m#(PT6Z579n<; zUGkRKhQdN|wkufOjcaR&`RhSJfqWMvL_&MBrHZ+&pPnAuq;tNophny_;1MQL5b3)_ zp@Q0M(;DBbg;?jj(ugD{WLv6GkuxtuqWsu#)!<%})=5z_V0-8(nr(RNREhV71nu{M zxsj8M23*JAwKiHknBHfUWx8H*Jr`0OdFwUAU*{dP*pG?$~MX!8=@Ur+^FUe6r zGj-T~!$!)e4*;3-S`-oQE%`k68E3FBo74S3KO^JRfsmmm5e5bZr|s!82XHU+lMQ7K^Hl&ZGrp<1YTV?VDfc85NTGdqb?9-O#eI z+&tz0aZl$N{m!4Ir_1M$nx5|OuSGq3<6%C6~d_GaXwxv$%#E)qC0rC0uRESl4NdvE@2zxBttsF37qM`PXnk%C*UrQrC9XUa;=%Rv*?`CBqJ7XwGoacx>i zK=Wbr|KjKz!|QCfAlz7uZ8T=%#Pywp zVIJBn&6%;;C>uROznV!P_4S3vmBi|LS0n(WX-(YxnWgj6gDweVJGJ*Biv-akl@~;u z1Mjo`QhdP~aE5it?#-^JB&x{;`D`xp^9ZHgYnJlE9wD%o;3Wqkh}PDxPxc=rQxc%fu^m?X^t?k%j`hrHi2l`9%Eq=Dw*8T{7Azlt8W9xf z#WC+cTX7-pkFG8t2zQ*3$`^b6q2&@VDM8B1n23t~GnI6!Zzv-UR{butVB=iWq#_ndjD?TC)@6K171!Jk|>dn_;!*q2fx}UYp z9TQ_wIOy8EuH<0LZI^<9*GWbC!L>~POndUK@ve}G`7d5x2Map|`Efw(F*xkotGeC~ z@5hIyr{<$Jbo?;GRE(Qwz;xE7S*Xqzl+DDm!xw0gS1o5vmfOkVN{rH7qUDNje|YVo zyOa#lsT2g5EGIRV?|=D%Y_wSQ0jr!N0i!u(d;#ya12GTblm*+))j6sH8(u}k`-$jL zxaXQSpGa$C@XH9Rqu`zs*WaJ{t!#AJ|4m+pdzN)bfLAu9ZgHKlbE0qDc7Nh(3c+NK z3X)s}GGvIAJV-XbsScf%CMC~`d+!TkL}4?D4IBBvS%?&CP`QNskRr7r4`X=j9-I=@ z54RxIhEU|RbY-*tbUD2qVi@FCqjX3VufKJXXECUR==nrkMpt>A9U^B)u+&MTQHlir zh&V|;NmA=RQ@=Z04U_VaJ(NG(UmG?c^%(R`c|9ytyE@Zn*17pw?7HIj#zI0t!7dFV zCrB0$`~6{lmZ07=S09vDH=K8r{lotrHpi%4bhiL7CYR;xYR1F0D(ps$nLUI(OLN1{he&c;ncH|5F4g>D*&v<3L2aQ)ih{kbfeHDUPHK;PiuC!U2EA zch}d9r(bS_0MR3`CElhpbA1z+_dx(BNh>(sPc&Ds6KOs`v--2Th}5ta)$ps$Z~+{% zc4cUiR@56CcfBsXFrlZ;U3`6OJqq0IB3k)d^hpCguxL5`;R|f~>IRwj447mxGjFsT zKIhLJK0o^Sg40RG`Nb4D^2_#zN*6z${?hFR0Cz>a znCY0Lj1ixO#P{4iTC#yc+ikr*+Ws~~h|6I_(1`35mQ8*8gOV&^sk+^mXh>Pl=W5_69(A5%uAuk;7<2nRwf|h znNn-ii}#EjTJ%#ZQ1|$^wmgEvV*lO^1%3qyI|F zK=OnYiY;FCI=R#a1bfee1)zb19%GfFKwCy~v}1|}t=r|SQvtu1JAP(nDZLWK@Bj8> zEl_#DskEwy3(+u{9>!0qW30uK8BLw{To&{RDzlqILLLzR)K|I#2YY9Z`8MI6Qr)I& zd7`+*$kCZu$PE8CpkPhc2Oi_NPx%W5`M^N?Dpsed+GKoX!X5%;b$-_?)=S&Bsm*8e zq=Von0*tj=x-X{mRaHp=21g(RCC{pb@6X*M)EK7_$B~A=-5ptm`<~p-&@hRUMiCFq zip3!Z0btD;Y^3OCHZVCbTmE2)z&DZ;5!ObK9iEY^Vs_$gM0kc8-0t)t(#7_RrSO&V z`jeqi<8gdr?eHNcVHzZHgQ zGI`rihWZGU)Qy7ZwUsPqo|tj7HcKqiS7;kr2`7<)r7Wz+b#yY)my;A4wBDncw!-@; zNlECV-mU#S+hywiQ6!0i~4Vr=hqJFQIXwmLb z>*v1}1DSRXDDg8r+-*N5ojiz)tNLz7V9Kf$A0ZUCq&-T87kZgZ4D$TBeA(8tOA^>^rIr*GF( z)}d74j62B`QSf*H@EEqbTr7C+=MO2oY~gx)L)d|t0LN|n?*kR3>_bxmee_Ot+ufRPuN{ zDIhn-LdZ&03g?#KpcC@Ixq(bvI-~09C z(>VLC)20j{Kw@emZ`w6_eXTi;X4kw+Ue=-O~c2#j!N#S_x1n>Hd6GAvIa8z2}`vmW!NHYJC|rO4GEUj zk$Joy`2l9=+a?{FAuAQgfESN}mNMuDk>=T9L_!{|g-W>-$YPG2r<&u%MtqLSGp!rw zFyJ#jD-Z7aOMe8dsWOxspw+9c{=VFnHW5Av+vB&6?Y=a75-BdjIPkNnmd{du`Sc$~TlCj-K=PYb_Ol3baG zD-u=cA+Q`|Jga1OtKDnmj>z9?&*?C$qR`$iCeK=Biusce988K>7~Z8ziFR z)BdpD_Gn(FTDkPJUN+PV6kVA8@hQl$O;4p06V7ERhQw$W70sWw*}x=v5NO=%gWNQ2 zj0P4)_^)`}Rxbc_K66AvBp$?U{$Vr;49!`OcMglzhQJ-Zo7uC`W_5cuN2<&-w3Fr= zlUnDWzjCjYx0@ZGg0!soLTn^9&@o>np=}T4u0=_*WM^T9=d1Sflz!14lO)_g^Y#hQ zM8a#7#{2PAi?ET%R=STj;0_0cg&6XLx@blvXT=IR-|fY~undUtXRm-SKD^_t7U8W@ z91nB;!Dn{43UH3^L|#X`;4oX;OQ~dJ?4Te2*lnzww?JBJac#km$4Muo)A*}N1h$+; z1Yp5KM(68LGIXvIv3QL)84C1;p5kKWRJ5gpg~{v}qDPoko2=0(5+tvR!ypnyYSLG? zas-pr>ZX0YAD`|{Z&KORU)CbO(@sR=*N+`CYZl zO87UpOJRcv#g2$x4TRmD=)ayl%M`cs@(r|PdW>bg*4=&xAbs3jJMSq1&|vHgeGLtb z_Zss_eOadeaG0!ANSuGw>R!B++d=|-T)T}sv2deBuFx1V2=>;g)#=4;xpdexNF|c0 zPo;wwrA9eOFb1j%;FpOwA(=(hn+`BX6LY;<_kWr>O3I!Izx;?)Vo6?vUDs$NlF{ey z|E5_f{>jgmL~_4@*ID-naM%FLUW6t*cnK9&XN>~^KK|pw(2J`U@MS&r`sVb@jhA|Q>G0(MmNJ?yJm1ODNtT-cgs6oF)zjG=qEa4KyG4Z&O~fJchq3n0^l!0f@}8Q8BqIPJNfwwS6z-CDpgC- zg15cb1g+9g;;Up6-|{d~U=c5V?ZXqu{@c=}OH%Ane3W$Kx0LI|*{CQ7r?f!6Kv@nW;Rns58%x zqin_?S!l(695xr17V^k~fc&o?17xU|%d>|(*i5=g&Nuzc*<*3`aJt?qh00JX`3XA? zK)bzaeKSB@|Lo@TvFU(e)VGLAh;O166*LH=)2Mpov{`v+&aSfGUz6m&Sof#4Tg^Sq zDJl8=Ohk0Ss7Dm`Tcq(0|93{a&-JDka&`~{hppy(uJJ1ec^)4ri9RWIF5(I@wmAtI zc>7$$g7=)$7u4mysAQq4(qaYEY~LZjjaiJSh3VCmdkp*=R^;noizsh9XD3>)0D`i@ zd?8L&Utjgbrg}w7H~1X0hAu}z1fhF>Fdt)q9oD}KtdHaQ$z#=h96>&5ET%9s`SfNCd;5*nd=bVfqE6{TKa81vM z8R3gbrI79l76PNd2+HKfX~ojuH?ad_*>!MC9yM+2qzzjSvFDP!n(J&NMK4*$nx{1GCUDpo12@X z$45g+Q=~zhR+{!YZYc4)R0{c(>o4?JJdsp2F)Oj5uqcUoj31uzWq!V1{B8~le|b9N zij{h{a)3pcxT8@zMH+%uof>3G{5}+$-790VaOKe+=7U8vRGm5Y+4ZY-PR1Ed;82-9gpGt@^eAUO~qqqrA*2vqj8j)v0zdh6hDa$ zgdGPt{N%P1+cm7xmHd^iM?zW|AM4pXtHMQ4FA0i^sUPrwQ|EW>0}OFso0AC%7_g)V z+(goa%4Nb{F6tiM-slLweL|L1v(SZR6T!mH;eR&)fw^j8uQ80Fu0tHCwrHpTm!}2C7&h z%>-Rn1`K!%Ajd>!aoh|jC1Sro#IT3U9hAhYD?p``{2Ty6h?m1xCmfL*4YQiJ)#3R` z$&<3&jO)}wz-~8}qv4>q)lvTMv>aDu2S8q@Pv}-ZG1hCCH{QVT3otVuCzO>TY%r#$ z?5|WQOqTS9!mD4-n_saIR^XdgCBYD6+|3pb(+~ix-W3kF__;GQ3ub_S177cYkcwC5bIC+lwGEK@Qi!mmuQ(c7q;=&GuNxBX)j>hM7#pb?6r@J7WM0=oHnHY<#i0aD~BM< zm1a47^}2i?S@k9-ir(__jVIQcz3l#KW_a5qIOvfHv@bR9JT?IM^NF?7OZ|ZR8Dby) zda9m*u1W&Cspk1#eK78E_ty^-(~sPKJ8UPwHcv)HRXj2wZ`8IMcx=2fjCt+r9qyvp z>ap=$KEVmQ#RTNN)}EbB_Oct%P>iqS`u82M^*cSRteWCGvC+}cl+=fOd(cs;^mqwA zyZ+oGw|jqX|E{Y`D(hfQ%h7-KI}0f#UaZ`l+o?jJQb4P64GA!ztL;au>hNS<2{(|V zen_TB^8d+V3iGk=n7~6JNS92@#+~xz_o~ZxhG`=bSoIFGv?Tcc2|9K0gqc})ADUih z_{hjzj4+l{q_*dY0ljvN6~Pb8rmi}U@4p#grqEGHXfdwwlZyk)?O&1cl=iO@j9-CO z{#Pw#K#haSUz5ml!}Ntau$vsY=XXHz$>4Fn#Js<`q4m3-*@xF|I;TC|+0j8-k&?<) zJOTrk!ZTE-P0@n@`#_sW43%IHOfF>XyJ~{~!=KBMqALc@U3c`VB_w61r|dS&cgMHb z)=Oh0_#Gj90f&hHl@=J;*?cn=M_hkATfP-H)NZ<@+d4e8izA8L-Sc^RvXt#1Gv$6b zovm9wNf~wO7r!dzLioj~-Ffo8{{99UG5dWF!r}4|s_qo|Y?Fsyo3afO+~wkK+Bi5Y zCJVszJ!iAdP;NJ3;$jlcR=G=-v6uOEAoXV#t5Vn5_I%HZXuf^i9|a)l)JyJL)g7Vm z(44iP5W&qJ-}i&{NHo*G<*O}xE*9e_pWiY$fv1s~h6N2(13Oq3mP55k<00tMrB+Z9 zO0uA&u4Pk49Qh|MDiZq4VEr8}VyQ7I7vDFA$YbW&T|C4WA@aPc>txx+O2to~#gp!qXYjovj}-0qj6V1Pc&z^Dd?Z6l{QxoPJFGG(lN{1jF`MY-GwK zG22^DojDWyun9c&(*@Lz+8RrP`mHTNO0WqG&6S~Oj={I?#w*RX5eNsR_d06XkmRbC(Mk{68f+V@7;C{K0eZISETBjJbDMl+7n{hi^&+qH? z=#iAZ`W3d?+{nmylt94qAhtN%8_YY@4e-5h@x3DP{(Ym%A4DE2MP>OBM&B-10@; z4i_&A;FcKMNNBMd2?RE5)(X9|nE?)gmR%J#kE{h7-grDxrtnFHW@CvOXo65Leq)V4 zUh>G3kP@8pBP&E$+P*wC+Rk#Byg3x@C4q55zJx|Up#ESqeJnp)g%aG~s=(=&Cl`*X zA9C2E!LL+1>pho$XPvT2^Nz~YC0i!A+QfFlV2-Y?3_4}< zyPTC-f%WMcieYfshuE4n!M#F^Bhl7H_j>g^J3EAlR~=V;r3A|8wwo<&1j)1I-NJ5GE+%tkn3f63|_i61M7Y)L1vAH#lDkctnIu@XG*g!UFg_um&Rh zU;a`EOjZb3zwG(n2%A@%FD@v6m@?=eo}KrEVl#zmzsj)RYc(Fn{Aoy-`@7mKrhf2~ zGRgQ-XruWt@%$~UU%N15efKe)+iu`QvrKC-N3tdncr~^qYvXNa|{IX`Lf##93{viR$%hL zMkFM0UB06xjM5ZbqMs!(%lGnm^?lx3rTB4tuh7Li)XX*N;r2EnX!~y5|y9q zmaCHDdKO&i-zU@fVXo%)dvkC@>b3@&ocgenhJa=@u~sWD0}NRoE_l8pTce+a)+H_N z?~iIotak)&+G;*(r!!`9uQ{sn*FfiJOK!>ogBDUSZ>x#=>3_>U<9e5Kg=NnciKqOc z2ry>+rBkEcJbD|Dsq)~>@?*ft@_8<@nBLUmZQ-G8T9JCI1&ZHX{Yx=2zWm z^`#HY#B9f{zC-hW;3$vw9zQ9OilV#dWN(%!aUR_w#7c114yEzVMd6hUW&(Z_ygJ=r zv&7#B+?zX}M;`m)z!0P0uBDSf zX7>N~Fxu!u_w-zmz0vBqZtBk2MA*U}_5~e@i2J3YE!|q>0ld?$DlTqOrk&4gZ)*1a z_2C2s7n8wIMDe->uyuKlx5j`1ArTeiz25Yo?~RC;ZrLzws>b6{&=0_%PH zy=GUi{@0Y1T<#4+xn45Y&m*r}@2JDX_hT*Tn~Q(D>uM~T=p6+lxw#;z{}Gkesl^>9 zZ1V^ngLHsB3v^==317Tq$%Pif3B+otRH1N(0WWUUd3`+|M)5)p&JPKsAvBbjg`Ugv ztqHaBV^zoAi(4^pJUEXZXlyYd^k~@<65Kq~9qT&BWu?p#CH14FGqCSwqb1a=!h*~w zNY+CtRh$$!>acLL@2InUB}^-*h48KcpP-Knp?eLshPNz?dJC3W431gFO03wrUG&*=sF+vWw| z>(8IKlYi<+G|%+5G!+w+Ps$eUK2G-c_p^TH#WYYWDNR!WRJl{=`v!K-g~g2QqRcMG z{S1}4A<%v*AyT-OyD6XR`bzsl?}0?)|jwo_lcxmdpWHHjH;(boQP z*qTFJ*?^POih=e87j}eQ(PK}}8~9!mBl*+L!ip|efq+$LnS`Lt+STIwU9mrP@)5_G zfB$IMmWAU&%2*+ag5WWaSwonva%m|jqR0viHEn30jDe*NA$N?O7_b|q%Nhy`?-B#hd|~dA zH3WC}M}G>m5ijpWgSwNVTseydEt}W85EmvIsapG^t&cj17@Qf){%WE9!C0b+sFx>V zOzivAveY4^{m~S;UEEUlF48*mq{M%9^ICuc5gR48BQPE(cDCdL7jghEo${}ediRPE zvF+ig3?lbXmT-W{K3WWJqBs)o`k@lV%av9;^c>VSF;kmwOtL^H9d*{Z+xK1FsNvg+| z{Wl;`*L9)G`9-t@VMS&841rFk|$c?Ultf$G~S)r!F>I~+b^QfPb> zYrQ%ar(NrDI;_lxp9hWK4i+_67cG@=5n~#!pGp|68oWK_KmqwVhboJ8KQvS5(h&ry zcX@qZe3p9$MknObd9(V#M?!&jq1|pZ6|oJlA}ov#4Xut7Ddz?u;UFMXS4E9{xnbB%7r&-+sf z67`L7ZP$$3i}%-`OJeS35nnnNR!0#eEH)P!jUR0Ryis?}x7YBV+ybl$p4h0UsBCbP zmF3l0l}&yZBu({*QyPALA!etitsY=`Ul#X@Iup7!+B}-b;+<)bdE_q!Zquk%QG@7@ zBAVW`AUdKLLUx~>yHfPn>uqjtTWMG@(rR(;>c%|63X#(}ygkqkxQ02}EI6}6?3@QK z&7Lx4Y2qV;pmP65+uyLD5#AcbTMw7Fgi*)Hj4$tdmp~&r$ zyd1XAadhG29ui~SyxilvoIVJcijlcdDtRd8P|F|BnoroCY!n(x0%^zd&x36}K%HMY z&y$nki4H^&^Bn0UExyui_>0~3-guHgrI7Vt@Yk3Zfl#Xa5)N=<&~mi_-L!#AYSM7=bf*vpi5!({!*q zq+q(fzJ5ji&oWKckHV$zA1ClJ%O;LuN-LmTX>yY|z!LF3ExI0E)!+yCsG8BWJ5ftt zY#(ndraGT4mIY5_@XoZjwlRwE`^V~}W6I2^1^}fUY^xp4qb*f5Ue4Kid^yF7S z{?WI#j4reFGSBkOX8jQt>1~D_-4j-kgMcZt5^cQkw7?0Evg+#$QzN|E>QgrANjEcY zcgGF6wVIH(sM3kW%N?^2RMZ*|wLJJ8YhJ)mQv@e9q)30(=OHMRR=y8gDu0zlxb47u zOzQ->A3efoU?`tIEnA`1R5N<`I4KcmU=jD$Bg9pr9%MhmL`hlXiM7r%MCY+^H=;qx z%(Hwo_Q!l@EBur*%yHi8{xM$X^$w|J?{qiwFEaFw2)osSTYgoQ0&APu1kK;ggBa^? zwtRfwyNPvwINRu6egscu^Ljo+Gy%bK35%&{){|m!B>Xkc>0Yi#ePfs)xW&K@o=xe( z5w%>OPni8S?0-!Lc2kOYr-u}C3QQ&0Wd&`Mx)$rTX(pdpS1*~Z_gUd{l6CHj3;HJu zrJ1tQl*B0Us@qK1=qST@#Qis>EL`kp*4xUdf@zce>F7|m&NB?GEFV(luL1NH@MF8S z-|4-TEvG)8U?~KN+IlWfIo9bG=*v_wdgU$e&>c8*Ww*g9Y7`?V|IS%NmTuy-v6CDC zD^B!r#4;n~&nXYhT2y8xZ@O1$%onBc+=UxY7h*v={gAkkfB>!@y31? zC?BU|ji=9nf#)qcY%d?ZAg0?-oz&}oC5pEV=VxKhZrcAhpz^u|{m0qSQT)~2-k;m8 zcTO+Ya5dJ$qxsdOks#;mo>&wH(&{PTTqEuZ-Sg6VG=gNaGWpHrFHG+ck%Q9E-OsD$gz6d1Ly-w z8$J}Hxsa*U3&ZVYj6URr=(+2TIjg;~-RbZ^44-fZdPpq#{*e(2#gwr>=#2ZfLh8lS zyjtOFvcX?xY}iRCZGtRYQl1?{%B>|?>QRNLMcPnepk22WOZWzIPkI_!+)|~>UX7xX ziLZ{X(Lpq`{#M7Zy#eNUr0g+q3Ko9|^+2$-FrOc1T-Vy~+Fo^Ya~8om1rjQG6?J>)%ACFTt{0Z(`wp8kNU^pH_j4c+gycZ=!LIV*5A z+Lt)X`M~7TnTvNX%N7^_kP9u`ULWZ`gs4nLGI^xMM(eIRf1H4^_fwJn$z0|0d_xkS z_oqXLY%FffgnK`5Gt`2Ydxw$*8CnF89DF?1=yiLAHm|u zUN?I`b(cP_cZ(-6b8VX;5-<#z*fnS4$s}K1u_c|6R|iRvV1^^FKHkGibc-)|lBLz$ z8}}dt=(xDJoIWyezrEa@7~VRKXSO}$<$fES7@Ek$Qag~|z7X5$Si(Bk7K$Vh2n zM05BEJj_lp3;YFfcX!@K& zA@GPH@h9Wvh7PrbgYnc<#0n8B@1d>%41AaXQ^6Ev2Hq%eZB3klzXye*PgNxTr_jvu zGMwuXLC`$7HK|&Y1VBurV=I(Nv0?W;>2GVy=<+;$STP<_cwZhLZ{TP*Z%p9MFRo!c z=XSfi0rbKWS~Xh4l{V|s+%b>HQJUHsH{X&rMe33^prWn_5b)op=jR(R7-eQ3;&n}} z?R^biF)T*GEfz7iiz!wcCFtQcYw#&3DGd*?Kj$iSZ+!rRp!^q_&hIV`{)nU$kidn< z{_DV=NIpB>Pzj359UZiD%#dDpQI|{18GE4S0~s-hKXh?F9X`5h<#9V_ry}JyWP=?fAR*>bkNmv^1r7ZK*s_V=8ol`T&d)vn z>jj5!vqSLh8-=jK3cFODjk(VS(S%us`0vg?i*U{|~g+PXfcY(zn zXXuA)LX=d~@9@%f1t|_p3T5kQ^qP1g2!VGq=k@M}3p=(%%H4=gdP&#{&ILQL_w7lNa)n$Mq?rKCNQD1P zaVkI>i)1wztJj+WCK2mPn~Fs;PqGJ_Fm>1Ofc}Aj$*F1X^u->7KXk$dUt`3>7hW0AV#Th|-O_aMP?i-mfnvIaoJr$i~> z)bxLUi75fj;39hMKI{mP(h6%4ynK8tpB1z9c#`9`&F1%G(8~~pSk?}B8_my;r#WPW z`!zK+JX8teUPl)3V|tc(xL&m zj=WP#JGgvJ4JQ?->`uUUGn}0gFXkXdma=U+U;FU2!FsV7&i82DzDzB~Zmyai_1c-{ z!}}g@z1{VdrxA$`7Hq`4dvJi3;%_dt&N!5o2_;5;J?nYebo}>s1yo8@1jI;7epz-n zY6<~C@I7g(P)$*oK6`XqdUQUW3`=sfBan+P>WVEI{IqHe^m@`rsY8JXZ@8r(O7KFj zIASElDq7zq$LOzvx=iOq;gpHAZcX!m^ z&&yXh^r5(wjXc+45g@ctL5O+TJwMNW749Xc57sjsctwqP#dIZFHlLLuNe)_RvcL6M zh^CPu;Wb1sZ$R2Q-VW?M02{#5$;h=;kW+BN8n&F~FYq7rr3{ z@hbmSZ4V5VxO=~SD$NG|dr)-^D;5!}Ok;HpTo4r+JR~df#U^`~Rs*etdB5KE)V47P zHy79au#NZ7Gdeuf4&c%G07Sc&Dy=%Ow(#XnxA~h9+to(kz72Qow3_joyyt=W2T8$G zoB$FOa27p+0rW-n&knC!2;N!DL|E%y@V3MnefqlYDC$6B+!Ta%EU@ zrWGr$7gx{Da|OV)TBAp%!|5+yv4!lH(2jOq1pSas^hEwm+Ij9ZO4oSKWlk}Kpk5gJpCiHHz>KL39-4cyX9k6q424@Z{VANrU9?2e}ZnKk{_#Gxht{ z74MuC;Ir>@=F3i%QXezxn<4cPkaw6zbgSKV^ifX2R_vjP1<))aHQH*bx)tIQE?Rp@ zl-XvYFw@6z%^KC{qf6R>w(-oBM%C z&DNy7fqC}vitJ5_*LZrpOQmajo*aYv>(kfa(F+qtasH`E%N4J|@8xVjo{Gw`QGA6p zaxDgiit8<8ookCiBRfWp*xnzUB^Vy-t9mpc`&$D5Rf=Q{0-5>>>+i%`#Jno++Ed$F zi!vqbChI-%H+M$~&M*`7%|7^=zV-4ORT_-yxCPh$uw%lj*FxgB{+;@&rFz3Q%Fsc(&Zz^6+;apdaIc zUVT^F%-uUuNsK1_nt=2(;wDC;Yja$ad;COCC1DMZE-;mu08^Q*tX?@_e`FiMD|4@dK+NbV#9L9|2c_Z4-u1MHa<|2)NSF6xR@d9(JXkzHZa6$4 zy11xJi|NBaN5{u+w3I8W%;eGe1{p%JVF>Q@Ljq2cs{)Hp)w(<$5g!hc4$Wv=hvSnW z0gVzZ@j~Z`X)AwG2Z=V+7-wZe2{(E*iQLP*D`LNC7iyBfU_NJMVK`JSu^3~%l_^s*Y;HU@h292^v9We(*t z*mR+9AJ2J8bcZfj4XV>YDvX?6SOFye<(u1I`m_C`hmY2PpDf65JEX3&-Q7%@QS(UA zpij%(kECB1G;h!$Yr8<*@1fcFZ=WWS)rISLs)|yCLwwH9hZC%s%K+)Zze#D!R={#^ z^4b`q3;*=o(qy-|Z;m1=ytcmP5t{|j9Ry%Q;ub5OE6m$C80?J=>AnS0_((u%UEMO} zqoJW;-FomQl z`$4_QyIF0z?Ux@X*;7(dSXM))+HPF;L)0@_ZB0!JO=fo;!QXSf0l}qEKzk@{&$;gN zEfple@jZF!*^I(|r5w#l!iF1;0)6HVj<3W9y}UCt%~7Dlcf~;Nv67P5z)6A&tHhF! z{{sUDuhM~y7-A(M5f%@eo9QIPL$h8%OoxNP4>CN~sq&pujxFz=7p4O*ouvH+FkHcri(5gc~^uQEjx zsLSd2&RKiHekJd{6R>i*RUcenAyC4HqA_hIShM}101cCyx~8yJ%h?8Q9K3OrAztyw~8+X0j_j?4ZkMG@r?q!GY8oAL9nC^J_rF zeuwMdRV_;$-}#rqkW@Ii-gfyVR%FI(z}|WVa7wddM~39BagYXeH328_&3z&M2@pH%B)j@090u>#ZI7 z^jIp~netlok_n;-k}iDw{M)_dxJi>PHk$_`!bXW}BU0te_ip`IhfE(77b_i;j7v62 zxQ3y9+vF`Rt+8)U$FnBp=7+VhM6B0itnIJsj>h8Azd6kZF(o$eBO?B@xlYE#L=hhE z?e&k_Y&hy6K*Jz+dR#m~Q}%hQ*eul-?l!}zJaUu$rq(8>q?`e=lEe_;;EaIf>>ib; z*A4rB&|xv2ai__2 zd&AH7@^)##*bh%|AZp1=u;MEzZzbe_JG_7M2;h^-|7&N*_Vj!kRsv_aFJa_?lN7>J z!k)7_!Pj~eBWe0OUY(%$!w3!wzc*o-LbK?(cQZj!aF>0W@24W%I~sjV7za-As?~2h zp8%jMq*N(iL@RS-#Vr*Nu1Nu3C8s5lpt&A6neeKe*?1`w=Yi#eURjev8L zQXKlU5e3bgom5a5jl?zNy)Gm`a}bDj=@!d=9@B$H-zV0`$0>7%e?Yjnt}P`r6v+`~ zmmbO_B{>H0=sm`N3>c7HykE~2R{~{b)Hfb|To}^u~=D2L& z%r!M4nVA_OviDJKEF?rOfH6fp%vLj#QCt#W=EDJ>{1+an16_4Gw6fs!*m4 zC*7TdbpV7u$rg2wFs9@P$1ZAt5Jtf7=FS|&Y2888)B4hoK8~C^PF|6yc!}3~d&g9- zi&5WGg&qrgG@1_<&Qq6AmEf}EqB9v{|0%L&RLX+8}BfK3ZX*~w}<27NnY8$ zrLvJKVvT@<@j*na{3`z#`woUXgRvU0C&$_D0qyV^*vF6R{c!($dNZ#F6sThU=Jv?P z&g+M|HB&oY&Oa?HADdjb%9Q^;tfpKxPjSGgse|_c3N#0W^Fju6@7mBA{;TR{-C@vX z%dCHSG?mG87L0cj^hVfd@V?yfek5{pbas~01!B;WcAt$tQoDMj>pSHg-d%xxFa+x25r!7_NsK-AfE zx$RwN-6B!Z8neRh`(V_Sq?xhZikUACfgY<3m^56A%;W4dQltk40^eVyZxY47;jb{k zycFB(=<0U8?=Nmz1OCqkZ<3@EAd|FYhE%?qQwsAVSZ{QAo&q%cuuqfFS=` z_5(ll2KN;i@_k6_NB~RZ^O{niVK;+#w)M%E4jRE?r1_c5w1nUqH7+u?Gu#f{2H5 z>o5a>FGf6SXLtAdOJF)fmft@9=V=pZ)gNCNr02=ZuI6T-Am!hax00lX5bs68)zQzH zr)-XousBSSu&wZVOHW?!k6x~_S1ZJ1TL;CQUf?>((V+TrOyEmYL(1XCM-v?tr|@KY z9TZxj&EcH^TGdunWjk5xm~6V-^j{)R8s*gMba1-o6pLDNInSf$YMo5$g0&X$ll+Mt zdrr=#C07dqEw&!3P>x+rS2zpnJ#7H)6D^0S>gg^k*Vfpqiwm!9Dq8M??NVbHyidip z11j&!IP;sRl)o78`?E}9TaZCQeVoZaGu#t+$U*Y(MjY_1V*&8igrxWn$#rNHV4mnv zVnM)N&=W9f-)NKgGUWK-3w>@woXdywyzP}@gGT3ZU)^TMwrT=m5@6jpazo`cklw4{ zUlQBk;NW0@Frc&5!JU*n&9D6Ns!fqt)cD6pK|%R1U?a5SG%;QlY!-CwM2jnEdA(|h-%BvL_GB%ojAX^)%9D@3!tJPu=-i7K12vtA1~4soC*K}6F0#9k-3`9 z2t7ZT$YO2N*z9b>o2z>De!R#uQcyTsViPw!s}!OpaxAAFg{(KSMQC7x1OA& z>%>!CaU4t_1-*fmThJ6hO9+ZSRr{FH2bI|)u3o)-1jD=nwEkj>LXoR`6qIr>t}HdZ z>JneTMI*RM+s$nyz-s43m_zEAoUWHoDT-u?Vq5IZ&D}8)g2aXZ0zxdt-FAimlm;@3amR(aVzfb!CKsr3w73>` zch}&-Zq7OHz4yDnzWL&n!h;LrQhlOFVTyxP}zLUosPu zQl7;!(}IH{^OqO*ou>_C)Pt&B3i*m9|MywE4yz?1pju%ldI#JE0t^M!N@=LVNmY&2 zM-S4`58Tv$J1ofzpRM5b%mh|NxMYG1Fa>T@RN+_!s9E8;r zpGs%ajoSQF@uSEs|D4q{Z*naf{9!F&&A0#lq2BM`_Ed5!DKS5@i*4t9bnt%y0I-Aa zSBsWF7xNZI=yeQmqs-!<6&QxX{DM}2=&P^plZE5CmiPXp)*Oi-u0ws<;TgAoQ7Qsv z{bHetH+}3AXLijtKtR~%B{U03i7RYl%!j5dP>hM;bPHzgIqG8GUItbx7ksF^$`nKY z3+wRDl&nQjIFtg0)76+SaF&W+^K;Z)5t?<&itc;(MT$wN{^yYZ!qY%!s5uu!K(PNA z9L#U;EFwqF=((f_>c;m(^ygzJN~({-S!lv4mc7xp!z>)8IxhP;V>lfFY+gwTTkwbJ zbJ!~>h7mD#1GgG(?An^mD>h{g`bq*WBCKp7pA8`L$D6WF4lZ0z{!Dk6RBz8;{0v!S z4ZnXjBx(zCQMQ>(^W|asO6N1*7*)>P88qZ1#h=uRW&U%gp??mw2T9JUB~JQO?CV`V z!|4i&0x_C}ZN&Ouz$o-X3_UD1nu0<_wXG~eN__!E@F-0o&8|429Fsv(=KLa|n8#a$qN%Xs9O|gfjwMERHQnoH>8KGvjuc$nK zWFgDDig1qYrZ+|Q!!aA;MCo&BFTI1=mz;09ab1M$8gzqKwrFKaXmv{Hz(TuzVySq$&Qn;j=38Z|7E$OXIL>S2)p%-*wk`D%X<`ZihHrf>*7??xww!8|6(5@Q=kPsuO}kNFL`^4=3b>O1 zu>T1Ou>{NQn6X13>oaKze1UUpnlw5fN4r(}AXFGaC0qJ&t2FeJykCu0nJ(Jb>gtQ^ zHR@Piy2j1f%g+gLbn2m9t(lhC2b{OQ1J1P-G7J_6N}ui|B>ge#rNb6rP*6a|2l=4gyEQEmI769^+KfnTP_kR7%7XGzZ_NTiwke+AXt2!A49LCw zo16EqBbmo{7%Ubw$;&A+3GDepuvITrApFv^r_U=%?a_b6&1593D-f4)3X_=^&*3>c z))b!(6{DDQkBuO%n^uofqLM!Pk5nfpcnKJ_l93f+(t^P1Qo!u(Cx3YNy+IksjE$Ru zGO0^MR6}hg`NRc+Euq(r>8*Ndr($vuPe7>CL3{WdV3p-snsH-=o5dtamJ?Qq-MEDf zAqpaGQl;VQZ`iuFR~8USzZf{27EDBk_B+!`^k}xUkjvo5OfatQM(M(({A0Vi6acV@ zvdU0M3HNu+;eHfa-`mKj7K&_N*2cZkG5&_C9;vF3K4)X%GPD< z8m=)_6A=l|D#5jUTe6y=Z%`01iKXZ&3yrKZZEFAQQdL>>)>Wb8Nhdt zRvgaeuTVH-86C$!$V|tSmwY?vQIY$l1vlft;{f9~+$+(fZkzG%H`ce|S?1wY$)yeW zNlz&&cjsBI_sqm7?t17 zilLilv~vjrOEVJTfkOl&q-Fm=Z80d46Hg&3(MkA83JLG{;NW{lM+Yh7XKO3pCq}u@ zyqkeNbmBNw2C=udx42eOjtz>;^*q#v#TB;zmn~mZG_)_UFrqsCsDm%4l<%yA z*9I16mMOzqtrD>NrK899c?eMQ;l>b(C&HlDARa^CCE7VV(Mr(_)_D$k@r(s78QH%> z*FUYSkArgRKiLo*1zK72Ul|%2UOzVO0YqX*x4(uy0+pA!LBLQiuLv2_3+HX^%@AB&W1 zP|dZNfBTF~`9mMXwNa|-8yj8Xm*ux_cKg#Z9_PF6C8d4IXuQ1PaMw^-T+(;hR} zSh=3EsCFtgYz;)CzAzSjFXjuoxF(6LH?PkQ6(GCH3TGx&k VADV5i;ks%SEo+6 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 \ No newline at end of file diff --git a/public/storage/uploads/image/2026/04/10/20260410132654_125e9fa2417c0005.svg b/public/storage/uploads/image/2026/04/10/20260410132654_125e9fa2417c0005.svg new file mode 100644 index 0000000..156dd49 --- /dev/null +++ b/public/storage/uploads/image/2026/04/10/20260410132654_125e9fa2417c0005.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/storage/uploads/image/2026/04/10/20260410132722_b07354346c3ab8b0.svg b/public/storage/uploads/image/2026/04/10/20260410132722_b07354346c3ab8b0.svg new file mode 100644 index 0000000..470b290 --- /dev/null +++ b/public/storage/uploads/image/2026/04/10/20260410132722_b07354346c3ab8b0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/storage/uploads/image/2026/04/10/20260410132828_1f3f2a6ef52e1bb1.svg b/public/storage/uploads/image/2026/04/10/20260410132828_1f3f2a6ef52e1bb1.svg new file mode 100644 index 0000000..18f4712 --- /dev/null +++ b/public/storage/uploads/image/2026/04/10/20260410132828_1f3f2a6ef52e1bb1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/storage/uploads/image/2026/04/10/20260410144242_3209013f5b3255b0.jpg b/public/storage/uploads/image/2026/04/10/20260410144242_3209013f5b3255b0.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6e391c894e478101759aec602ac2d63754e2a2b0 GIT binary patch literal 48112 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≀9p;wQDkJ=XgsguSD6nBLs0;fsK@^RUpCZ?`Vrwg zh?^Ed)_2?F|9_ie|NF4%f6sXT=ltn^UuxAWa@u*`sx3-0$39#WSk_gJ(&nc~xBPdK zi=|X!?cCZQp0E*JHbrHEE%P9nE(S)`Sd*`IKaqP(K!HiTMEU%ahIJ2hx~T0K#Q0*# z-^0GpU;RVHc=4rr@ue8q+;QKD9LsH(NAL+Gj8mKY`2Nh;i(68pm^EyA-qG?_wiI`& uk52N2@%NQ*c66Z~&L9;xwoek(*DbhT{NHu>|BE61|4Z}#zGVF0*8c+{MWO)! literal 0 HcmV?d00001 diff --git a/support/Setup.php b/support/Setup.php new file mode 100644 index 0000000..99320e8 --- /dev/null +++ b/support/Setup.php @@ -0,0 +1,1558 @@ + \DateTimeZone::ASIA, + 'Europe' => \DateTimeZone::EUROPE, + 'America' => \DateTimeZone::AMERICA, + 'Africa' => \DateTimeZone::AFRICA, + 'Australia' => \DateTimeZone::AUSTRALIA, + 'Pacific' => \DateTimeZone::PACIFIC, + 'Atlantic' => \DateTimeZone::ATLANTIC, + 'Indian' => \DateTimeZone::INDIAN, + 'Antarctica' => \DateTimeZone::ANTARCTICA, + 'Arctic' => \DateTimeZone::ARCTIC, + 'UTC' => \DateTimeZone::UTC, + ]; + + // --- Locale => default timezone --- + + private const LOCALE_DEFAULT_TIMEZONES = [ + 'zh_CN' => 'Asia/Shanghai', + 'zh_TW' => 'Asia/Taipei', + 'en' => 'UTC', + 'ja' => 'Asia/Tokyo', + 'ko' => 'Asia/Seoul', + 'fr' => 'Europe/Paris', + 'de' => 'Europe/Berlin', + 'es' => 'Europe/Madrid', + 'pt_BR' => 'America/Sao_Paulo', + 'ru' => 'Europe/Moscow', + 'vi' => 'Asia/Ho_Chi_Minh', + 'tr' => 'Europe/Istanbul', + 'id' => 'Asia/Jakarta', + 'th' => 'Asia/Bangkok', + ]; + + // --- Locale options (localized display names) --- + + private const LOCALE_LABELS = [ + 'zh_CN' => '简体中文', + 'zh_TW' => '繁體中文', + 'en' => 'English', + 'ja' => '日本語', + 'ko' => '한국어', + 'fr' => 'Français', + 'de' => 'Deutsch', + 'es' => 'Español', + 'pt_BR' => 'Português (Brasil)', + 'ru' => 'Русский', + 'vi' => 'Tiếng Việt', + 'tr' => 'Türkçe', + 'id' => 'Bahasa Indonesia', + 'th' => 'ไทย', + ]; + + // --- Multilingual messages (%s = placeholder) --- + + private const MESSAGES = [ + 'zh_CN' => [ + 'remove_package_question' => '发现以下已安装组件本次未选择,是否将其卸载 ?%s', + 'removing_package' => '- 准备移除组件 %s', + 'removing' => '卸载:', + 'error_remove' => '卸载组件出错,请手动执行:composer remove %s', + 'done_remove' => '已卸载组件。', + 'skip' => '非交互模式,跳过安装向导。', + 'default_choice' => ' (默认 %s)', + 'timezone_prompt' => '时区 (默认 %s,输入可联想补全): ', + 'timezone_title' => '时区设置 (默认 %s)', + 'timezone_help' => '输入关键字Tab自动补全,可↑↓下选择:', + 'timezone_region' => '选择时区区域', + 'timezone_city' => '选择时区', + 'timezone_invalid' => '无效的时区,已使用默认值 %s', + 'timezone_input_prompt' => '输入时区或关键字:', + 'timezone_pick_prompt' => '请输入数字编号或关键字:', + 'timezone_no_match' => '未找到匹配的时区,请重试。', + 'timezone_invalid_index' => '无效的编号,请重新输入。', + 'yes' => '是', + 'no' => '否', + 'adding_package' => '- 添加依赖 %s', + 'console_question' => '安装命令行组件 webman/console', + 'db_question' => '数据库组件', + 'db_none' => '不安装', + 'db_invalid' => '请输入有效选项', + 'redis_question' => '安装 Redis 组件 webman/redis', + 'events_note' => ' (Redis 依赖 illuminate/events,已自动包含)', + 'validation_question' => '安装验证器组件 webman/validation', + 'template_question' => '模板引擎', + 'template_none' => '不安装', + 'no_components' => '未选择额外组件。', + 'installing' => '即将安装:', + 'running' => '执行:', + 'error_install' => '安装可选组件时出错,请手动执行:composer require %s', + 'done' => '可选组件安装完成。', + 'summary_locale' => '语言:%s', + 'summary_timezone' => '时区:%s', + ], + 'zh_TW' => [ + 'skip' => '非交互模式,跳過安裝嚮導。', + 'default_choice' => ' (預設 %s)', + 'timezone_prompt' => '時區 (預設 %s,輸入可聯想補全): ', + 'timezone_title' => '時區設定 (預設 %s)', + 'timezone_help' => '輸入關鍵字Tab自動補全,可↑↓上下選擇:', + 'timezone_region' => '選擇時區區域', + 'timezone_city' => '選擇時區', + 'timezone_invalid' => '無效的時區,已使用預設值 %s', + 'timezone_input_prompt' => '輸入時區或關鍵字:', + 'timezone_pick_prompt' => '請輸入數字編號或關鍵字:', + 'timezone_no_match' => '未找到匹配的時區,請重試。', + 'timezone_invalid_index' => '無效的編號,請重新輸入。', + 'yes' => '是', + 'no' => '否', + 'adding_package' => '- 新增依賴 %s', + 'console_question' => '安裝命令列組件 webman/console', + 'db_question' => '資料庫組件', + 'db_none' => '不安裝', + 'db_invalid' => '請輸入有效選項', + 'redis_question' => '安裝 Redis 組件 webman/redis', + 'events_note' => ' (Redis 依賴 illuminate/events,已自動包含)', + 'validation_question' => '安裝驗證器組件 webman/validation', + 'template_question' => '模板引擎', + 'template_none' => '不安裝', + 'no_components' => '未選擇額外組件。', + 'installing' => '即將安裝:', + 'running' => '執行:', + 'error_install' => '安裝可選組件時出錯,請手動執行:composer require %s', + 'done' => '可選組件安裝完成。', + 'summary_locale' => '語言:%s', + 'summary_timezone' => '時區:%s', + ], + 'en' => [ + 'skip' => 'Non-interactive mode, skipping setup wizard.', + 'default_choice' => ' (default %s)', + 'timezone_prompt' => 'Timezone (default=%s, type to autocomplete): ', + 'timezone_title' => 'Timezone (default=%s)', + 'timezone_help' => 'Type keyword then press Tab to autocomplete, use ↑↓ to choose:', + 'timezone_region' => 'Select timezone region', + 'timezone_city' => 'Select timezone', + 'timezone_invalid' => 'Invalid timezone, using default %s', + 'timezone_input_prompt' => 'Enter timezone or keyword:', + 'timezone_pick_prompt' => 'Enter number or keyword:', + 'timezone_no_match' => 'No matching timezone found, please try again.', + 'timezone_invalid_index' => 'Invalid number, please try again.', + 'yes' => 'yes', + 'no' => 'no', + 'adding_package' => '- Adding package %s', + 'console_question' => 'Install console component webman/console', + 'db_question' => 'Database component', + 'db_none' => 'None', + 'db_invalid' => 'Please enter a valid option', + 'redis_question' => 'Install Redis component webman/redis', + 'events_note' => ' (Redis requires illuminate/events, automatically included)', + 'validation_question' => 'Install validator component webman/validation', + 'template_question' => 'Template engine', + 'template_none' => 'None', + 'no_components' => 'No optional components selected.', + 'installing' => 'Installing:', + 'running' => 'Running:', + 'error_install' => 'Failed to install. Try manually: composer require %s', + 'done' => 'Optional components installed.', + 'summary_locale' => 'Language: %s', + 'summary_timezone' => 'Timezone: %s', + ], + 'ja' => [ + 'skip' => '非対話モードのため、セットアップウィザードをスキップします。', + 'default_choice' => ' (デフォルト %s)', + 'timezone_prompt' => 'タイムゾーン (デフォルト=%s、入力で補完): ', + 'timezone_title' => 'タイムゾーン (デフォルト=%s)', + 'timezone_help' => 'キーワード入力→Tabで補完、↑↓で選択:', + 'timezone_region' => 'タイムゾーンの地域を選択', + 'timezone_city' => 'タイムゾーンを選択', + 'timezone_invalid' => '無効なタイムゾーンです。デフォルト %s を使用します', + 'timezone_input_prompt' => 'タイムゾーンまたはキーワードを入力:', + 'timezone_pick_prompt' => '番号またはキーワードを入力:', + 'timezone_no_match' => '一致するタイムゾーンが見つかりません。再試行してください。', + 'timezone_invalid_index' => '無効な番号です。もう一度入力してください。', + 'yes' => 'はい', + 'no' => 'いいえ', + 'adding_package' => '- パッケージを追加 %s', + 'console_question' => 'コンソールコンポーネント webman/console をインストール', + 'db_question' => 'データベースコンポーネント', + 'db_none' => 'インストールしない', + 'db_invalid' => '有効なオプションを入力してください', + 'redis_question' => 'Redis コンポーネント webman/redis をインストール', + 'events_note' => ' (Redis は illuminate/events が必要です。自動的に含まれます)', + 'validation_question' => 'バリデーションコンポーネント webman/validation をインストール', + 'template_question' => 'テンプレートエンジン', + 'template_none' => 'インストールしない', + 'no_components' => 'オプションコンポーネントが選択されていません。', + 'installing' => 'インストール中:', + 'running' => '実行中:', + 'error_install' => 'インストールに失敗しました。手動で実行してください:composer require %s', + 'done' => 'オプションコンポーネントのインストールが完了しました。', + 'summary_locale' => '言語:%s', + 'summary_timezone' => 'タイムゾーン:%s', + ], + 'ko' => [ + 'skip' => '비대화형 모드입니다. 설치 마법사를 건너뜁니다.', + 'default_choice' => ' (기본값 %s)', + 'timezone_prompt' => '시간대 (기본값=%s, 입력하여 자동완성): ', + 'timezone_title' => '시간대 (기본값=%s)', + 'timezone_help' => '키워드 입력 후 Tab 자동완성, ↑↓로 선택:', + 'timezone_region' => '시간대 지역 선택', + 'timezone_city' => '시간대 선택', + 'timezone_invalid' => '잘못된 시간대입니다. 기본값 %s 을(를) 사용합니다', + 'timezone_input_prompt' => '시간대 또는 키워드 입력:', + 'timezone_pick_prompt' => '번호 또는 키워드 입력:', + 'timezone_no_match' => '일치하는 시간대를 찾을 수 없습니다. 다시 시도하세요.', + 'timezone_invalid_index' => '잘못된 번호입니다. 다시 입력하세요.', + 'yes' => '예', + 'no' => '아니오', + 'adding_package' => '- 패키지 추가 %s', + 'console_question' => '콘솔 컴포넌트 webman/console 설치', + 'db_question' => '데이터베이스 컴포넌트', + 'db_none' => '설치 안 함', + 'db_invalid' => '유효한 옵션을 입력하세요', + 'redis_question' => 'Redis 컴포넌트 webman/redis 설치', + 'events_note' => ' (Redis는 illuminate/events가 필요합니다. 자동으로 포함됩니다)', + 'validation_question' => '검증 컴포넌트 webman/validation 설치', + 'template_question' => '템플릿 엔진', + 'template_none' => '설치 안 함', + 'no_components' => '선택된 추가 컴포넌트가 없습니다.', + 'installing' => '설치 예정:', + 'running' => '실행 중:', + 'error_install' => '설치에 실패했습니다. 수동으로 실행하세요: composer require %s', + 'done' => '선택 컴포넌트 설치가 완료되었습니다.', + 'summary_locale' => '언어: %s', + 'summary_timezone' => '시간대: %s', + ], + 'fr' => [ + 'skip' => 'Mode non interactif, assistant d\'installation ignoré.', + 'default_choice' => ' (défaut %s)', + 'timezone_prompt' => 'Fuseau horaire (défaut=%s, tapez pour compléter) : ', + 'timezone_title' => 'Fuseau horaire (défaut=%s)', + 'timezone_help' => 'Tapez un mot-clé, Tab pour compléter, ↑↓ pour choisir :', + 'timezone_region' => 'Sélectionnez la région du fuseau horaire', + 'timezone_city' => 'Sélectionnez le fuseau horaire', + 'timezone_invalid' => 'Fuseau horaire invalide, utilisation de %s par défaut', + 'timezone_input_prompt' => 'Entrez un fuseau horaire ou un mot-clé :', + 'timezone_pick_prompt' => 'Entrez un numéro ou un mot-clé :', + 'timezone_no_match' => 'Aucun fuseau horaire correspondant, veuillez réessayer.', + 'timezone_invalid_index' => 'Numéro invalide, veuillez réessayer.', + 'yes' => 'oui', + 'no' => 'non', + 'adding_package' => '- Ajout du paquet %s', + 'console_question' => 'Installer le composant console webman/console', + 'db_question' => 'Composant base de données', + 'db_none' => 'Aucun', + 'db_invalid' => 'Veuillez entrer une option valide', + 'redis_question' => 'Installer le composant Redis webman/redis', + 'events_note' => ' (Redis nécessite illuminate/events, inclus automatiquement)', + 'validation_question' => 'Installer le composant de validation webman/validation', + 'template_question' => 'Moteur de templates', + 'template_none' => 'Aucun', + 'no_components' => 'Aucun composant optionnel sélectionné.', + 'installing' => 'Installation en cours :', + 'running' => 'Exécution :', + 'error_install' => 'Échec de l\'installation. Essayez manuellement : composer require %s', + 'done' => 'Composants optionnels installés.', + 'summary_locale' => 'Langue : %s', + 'summary_timezone' => 'Fuseau horaire : %s', + ], + 'de' => [ + 'skip' => 'Nicht-interaktiver Modus, Einrichtungsassistent übersprungen.', + 'default_choice' => ' (Standard %s)', + 'timezone_prompt' => 'Zeitzone (Standard=%s, Eingabe zur Vervollständigung): ', + 'timezone_title' => 'Zeitzone (Standard=%s)', + 'timezone_help' => 'Stichwort tippen, Tab ergänzt, ↑↓ auswählen:', + 'timezone_region' => 'Zeitzone Region auswählen', + 'timezone_city' => 'Zeitzone auswählen', + 'timezone_invalid' => 'Ungültige Zeitzone, Standardwert %s wird verwendet', + 'timezone_input_prompt' => 'Zeitzone oder Stichwort eingeben:', + 'timezone_pick_prompt' => 'Nummer oder Stichwort eingeben:', + 'timezone_no_match' => 'Keine passende Zeitzone gefunden, bitte erneut versuchen.', + 'timezone_invalid_index' => 'Ungültige Nummer, bitte erneut eingeben.', + 'yes' => 'ja', + 'no' => 'nein', + 'adding_package' => '- Paket hinzufügen %s', + 'console_question' => 'Konsolen-Komponente webman/console installieren', + 'db_question' => 'Datenbank-Komponente', + 'db_none' => 'Keine', + 'db_invalid' => 'Bitte geben Sie eine gültige Option ein', + 'redis_question' => 'Redis-Komponente webman/redis installieren', + 'events_note' => ' (Redis benötigt illuminate/events, automatisch eingeschlossen)', + 'validation_question' => 'Validierungs-Komponente webman/validation installieren', + 'template_question' => 'Template-Engine', + 'template_none' => 'Keine', + 'no_components' => 'Keine optionalen Komponenten ausgewählt.', + 'installing' => 'Installation:', + 'running' => 'Ausführung:', + 'error_install' => 'Installation fehlgeschlagen. Manuell ausführen: composer require %s', + 'done' => 'Optionale Komponenten installiert.', + 'summary_locale' => 'Sprache: %s', + 'summary_timezone' => 'Zeitzone: %s', + ], + 'es' => [ + 'skip' => 'Modo no interactivo, asistente de instalación omitido.', + 'default_choice' => ' (predeterminado %s)', + 'timezone_prompt' => 'Zona horaria (predeterminado=%s, escriba para autocompletar): ', + 'timezone_title' => 'Zona horaria (predeterminado=%s)', + 'timezone_help' => 'Escriba una palabra clave, Tab autocompleta, use ↑↓ para elegir:', + 'timezone_region' => 'Seleccione la región de zona horaria', + 'timezone_city' => 'Seleccione la zona horaria', + 'timezone_invalid' => 'Zona horaria inválida, usando valor predeterminado %s', + 'timezone_input_prompt' => 'Ingrese zona horaria o palabra clave:', + 'timezone_pick_prompt' => 'Ingrese número o palabra clave:', + 'timezone_no_match' => 'No se encontró zona horaria coincidente, intente de nuevo.', + 'timezone_invalid_index' => 'Número inválido, intente de nuevo.', + 'yes' => 'sí', + 'no' => 'no', + 'adding_package' => '- Agregando paquete %s', + 'console_question' => 'Instalar componente de consola webman/console', + 'db_question' => 'Componente de base de datos', + 'db_none' => 'Ninguno', + 'db_invalid' => 'Por favor ingrese una opción válida', + 'redis_question' => 'Instalar componente Redis webman/redis', + 'events_note' => ' (Redis requiere illuminate/events, incluido automáticamente)', + 'validation_question' => 'Instalar componente de validación webman/validation', + 'template_question' => 'Motor de plantillas', + 'template_none' => 'Ninguno', + 'no_components' => 'No se seleccionaron componentes opcionales.', + 'installing' => 'Instalando:', + 'running' => 'Ejecutando:', + 'error_install' => 'Error en la instalación. Intente manualmente: composer require %s', + 'done' => 'Componentes opcionales instalados.', + 'summary_locale' => 'Idioma: %s', + 'summary_timezone' => 'Zona horaria: %s', + ], + 'pt_BR' => [ + 'skip' => 'Modo não interativo, assistente de instalação ignorado.', + 'default_choice' => ' (padrão %s)', + 'timezone_prompt' => 'Fuso horário (padrão=%s, digite para autocompletar): ', + 'timezone_title' => 'Fuso horário (padrão=%s)', + 'timezone_help' => 'Digite uma palavra-chave, Tab autocompleta, use ↑↓ para escolher:', + 'timezone_region' => 'Selecione a região do fuso horário', + 'timezone_city' => 'Selecione o fuso horário', + 'timezone_invalid' => 'Fuso horário inválido, usando padrão %s', + 'timezone_input_prompt' => 'Digite fuso horário ou palavra-chave:', + 'timezone_pick_prompt' => 'Digite número ou palavra-chave:', + 'timezone_no_match' => 'Nenhum fuso horário encontrado, tente novamente.', + 'timezone_invalid_index' => 'Número inválido, tente novamente.', + 'yes' => 'sim', + 'no' => 'não', + 'adding_package' => '- Adicionando pacote %s', + 'console_question' => 'Instalar componente de console webman/console', + 'db_question' => 'Componente de banco de dados', + 'db_none' => 'Nenhum', + 'db_invalid' => 'Por favor, digite uma opção válida', + 'redis_question' => 'Instalar componente Redis webman/redis', + 'events_note' => ' (Redis requer illuminate/events, incluído automaticamente)', + 'validation_question' => 'Instalar componente de validação webman/validation', + 'template_question' => 'Motor de templates', + 'template_none' => 'Nenhum', + 'no_components' => 'Nenhum componente opcional selecionado.', + 'installing' => 'Instalando:', + 'running' => 'Executando:', + 'error_install' => 'Falha na instalação. Tente manualmente: composer require %s', + 'done' => 'Componentes opcionais instalados.', + 'summary_locale' => 'Idioma: %s', + 'summary_timezone' => 'Fuso horário: %s', + ], + 'ru' => [ + 'skip' => 'Неинтерактивный режим, мастер установки пропущен.', + 'default_choice' => ' (по умолчанию %s)', + 'timezone_prompt' => 'Часовой пояс (по умолчанию=%s, введите для автодополнения): ', + 'timezone_title' => 'Часовой пояс (по умолчанию=%s)', + 'timezone_help' => 'Введите ключевое слово, Tab для автодополнения, ↑↓ для выбора:', + 'timezone_region' => 'Выберите регион часового пояса', + 'timezone_city' => 'Выберите часовой пояс', + 'timezone_invalid' => 'Неверный часовой пояс, используется значение по умолчанию %s', + 'timezone_input_prompt' => 'Введите часовой пояс или ключевое слово:', + 'timezone_pick_prompt' => 'Введите номер или ключевое слово:', + 'timezone_no_match' => 'Совпадающий часовой пояс не найден, попробуйте снова.', + 'timezone_invalid_index' => 'Неверный номер, попробуйте снова.', + 'yes' => 'да', + 'no' => 'нет', + 'adding_package' => '- Добавление пакета %s', + 'console_question' => 'Установить консольный компонент webman/console', + 'db_question' => 'Компонент базы данных', + 'db_none' => 'Не устанавливать', + 'db_invalid' => 'Пожалуйста, введите допустимый вариант', + 'redis_question' => 'Установить компонент Redis webman/redis', + 'events_note' => ' (Redis требует illuminate/events, автоматически включён)', + 'validation_question' => 'Установить компонент валидации webman/validation', + 'template_question' => 'Шаблонизатор', + 'template_none' => 'Не устанавливать', + 'no_components' => 'Дополнительные компоненты не выбраны.', + 'installing' => 'Установка:', + 'running' => 'Выполнение:', + 'error_install' => 'Ошибка установки. Выполните вручную: composer require %s', + 'done' => 'Дополнительные компоненты установлены.', + 'summary_locale' => 'Язык: %s', + 'summary_timezone' => 'Часовой пояс: %s', + ], + 'vi' => [ + 'skip' => 'Chế độ không tương tác, bỏ qua trình hướng dẫn cài đặt.', + 'default_choice' => ' (mặc định %s)', + 'timezone_prompt' => 'Múi giờ (mặc định=%s, nhập để tự động hoàn thành): ', + 'timezone_title' => 'Múi giờ (mặc định=%s)', + 'timezone_help' => 'Nhập từ khóa, Tab để tự hoàn thành, dùng ↑↓ để chọn:', + 'timezone_region' => 'Chọn khu vực múi giờ', + 'timezone_city' => 'Chọn múi giờ', + 'timezone_invalid' => 'Múi giờ không hợp lệ, sử dụng mặc định %s', + 'timezone_input_prompt' => 'Nhập múi giờ hoặc từ khóa:', + 'timezone_pick_prompt' => 'Nhập số thứ tự hoặc từ khóa:', + 'timezone_no_match' => 'Không tìm thấy múi giờ phù hợp, vui lòng thử lại.', + 'timezone_invalid_index' => 'Số không hợp lệ, vui lòng thử lại.', + 'yes' => 'có', + 'no' => 'không', + 'adding_package' => '- Thêm gói %s', + 'console_question' => 'Cài đặt thành phần console webman/console', + 'db_question' => 'Thành phần cơ sở dữ liệu', + 'db_none' => 'Không cài đặt', + 'db_invalid' => 'Vui lòng nhập tùy chọn hợp lệ', + 'redis_question' => 'Cài đặt thành phần Redis webman/redis', + 'events_note' => ' (Redis cần illuminate/events, đã tự động bao gồm)', + 'validation_question' => 'Cài đặt thành phần xác thực webman/validation', + 'template_question' => 'Công cụ mẫu', + 'template_none' => 'Không cài đặt', + 'no_components' => 'Không có thành phần tùy chọn nào được chọn.', + 'installing' => 'Đang cài đặt:', + 'running' => 'Đang thực thi:', + 'error_install' => 'Cài đặt thất bại. Thử thủ công: composer require %s', + 'done' => 'Các thành phần tùy chọn đã được cài đặt.', + 'summary_locale' => 'Ngôn ngữ: %s', + 'summary_timezone' => 'Múi giờ: %s', + ], + 'tr' => [ + 'skip' => 'Etkileşimsiz mod, kurulum sihirbazı atlanıyor.', + 'default_choice' => ' (varsayılan %s)', + 'timezone_prompt' => 'Saat dilimi (varsayılan=%s, otomatik tamamlama için yazın): ', + 'timezone_title' => 'Saat dilimi (varsayılan=%s)', + 'timezone_help' => 'Anahtar kelime yazın, Tab tamamlar, ↑↓ ile seçin:', + 'timezone_region' => 'Saat dilimi bölgesini seçin', + 'timezone_city' => 'Saat dilimini seçin', + 'timezone_invalid' => 'Geçersiz saat dilimi, varsayılan %s kullanılıyor', + 'timezone_input_prompt' => 'Saat dilimi veya anahtar kelime girin:', + 'timezone_pick_prompt' => 'Numara veya anahtar kelime girin:', + 'timezone_no_match' => 'Eşleşen saat dilimi bulunamadı, tekrar deneyin.', + 'timezone_invalid_index' => 'Geçersiz numara, tekrar deneyin.', + 'yes' => 'evet', + 'no' => 'hayır', + 'adding_package' => '- Paket ekleniyor %s', + 'console_question' => 'Konsol bileşeni webman/console yüklensin mi', + 'db_question' => 'Veritabanı bileşeni', + 'db_none' => 'Yok', + 'db_invalid' => 'Lütfen geçerli bir seçenek girin', + 'redis_question' => 'Redis bileşeni webman/redis yüklensin mi', + 'events_note' => ' (Redis, illuminate/events gerektirir, otomatik olarak dahil edildi)', + 'validation_question' => 'Doğrulama bileşeni webman/validation yüklensin mi', + 'template_question' => 'Şablon motoru', + 'template_none' => 'Yok', + 'no_components' => 'İsteğe bağlı bileşen seçilmedi.', + 'installing' => 'Yükleniyor:', + 'running' => 'Çalıştırılıyor:', + 'error_install' => 'Yükleme başarısız. Manuel olarak deneyin: composer require %s', + 'done' => 'İsteğe bağlı bileşenler yüklendi.', + 'summary_locale' => 'Dil: %s', + 'summary_timezone' => 'Saat dilimi: %s', + ], + 'id' => [ + 'skip' => 'Mode non-interaktif, melewati wizard instalasi.', + 'default_choice' => ' (default %s)', + 'timezone_prompt' => 'Zona waktu (default=%s, ketik untuk melengkapi): ', + 'timezone_title' => 'Zona waktu (default=%s)', + 'timezone_help' => 'Ketik kata kunci, Tab untuk melengkapi, gunakan ↑↓ untuk memilih:', + 'timezone_region' => 'Pilih wilayah zona waktu', + 'timezone_city' => 'Pilih zona waktu', + 'timezone_invalid' => 'Zona waktu tidak valid, menggunakan default %s', + 'timezone_input_prompt' => 'Masukkan zona waktu atau kata kunci:', + 'timezone_pick_prompt' => 'Masukkan nomor atau kata kunci:', + 'timezone_no_match' => 'Zona waktu tidak ditemukan, silakan coba lagi.', + 'timezone_invalid_index' => 'Nomor tidak valid, silakan coba lagi.', + 'yes' => 'ya', + 'no' => 'tidak', + 'adding_package' => '- Menambahkan paket %s', + 'console_question' => 'Instal komponen konsol webman/console', + 'db_question' => 'Komponen database', + 'db_none' => 'Tidak ada', + 'db_invalid' => 'Silakan masukkan opsi yang valid', + 'redis_question' => 'Instal komponen Redis webman/redis', + 'events_note' => ' (Redis memerlukan illuminate/events, otomatis disertakan)', + 'validation_question' => 'Instal komponen validasi webman/validation', + 'template_question' => 'Mesin template', + 'template_none' => 'Tidak ada', + 'no_components' => 'Tidak ada komponen opsional yang dipilih.', + 'installing' => 'Menginstal:', + 'running' => 'Menjalankan:', + 'error_install' => 'Instalasi gagal. Coba manual: composer require %s', + 'done' => 'Komponen opsional terinstal.', + 'summary_locale' => 'Bahasa: %s', + 'summary_timezone' => 'Zona waktu: %s', + ], + 'th' => [ + 'skip' => 'โหมดไม่โต้ตอบ ข้ามตัวช่วยติดตั้ง', + 'default_choice' => ' (ค่าเริ่มต้น %s)', + 'timezone_prompt' => 'เขตเวลา (ค่าเริ่มต้น=%s พิมพ์เพื่อเติมอัตโนมัติ): ', + 'timezone_title' => 'เขตเวลา (ค่าเริ่มต้น=%s)', + 'timezone_help' => 'พิมพ์คีย์เวิร์ดแล้วกด Tab เพื่อเติมอัตโนมัติ ใช้ ↑↓ เพื่อเลือก:', + 'timezone_region' => 'เลือกภูมิภาคเขตเวลา', + 'timezone_city' => 'เลือกเขตเวลา', + 'timezone_invalid' => 'เขตเวลาไม่ถูกต้อง ใช้ค่าเริ่มต้น %s', + 'timezone_input_prompt' => 'ป้อนเขตเวลาหรือคำค้น:', + 'timezone_pick_prompt' => 'ป้อนหมายเลขหรือคำค้น:', + 'timezone_no_match' => 'ไม่พบเขตเวลาที่ตรงกัน กรุณาลองอีกครั้ง', + 'timezone_invalid_index' => 'หมายเลขไม่ถูกต้อง กรุณาลองอีกครั้ง', + 'yes' => 'ใช่', + 'no' => 'ไม่', + 'adding_package' => '- เพิ่มแพ็กเกจ %s', + 'console_question' => 'ติดตั้งคอมโพเนนต์คอนโซล webman/console', + 'db_question' => 'คอมโพเนนต์ฐานข้อมูล', + 'db_none' => 'ไม่ติดตั้ง', + 'db_invalid' => 'กรุณาป้อนตัวเลือกที่ถูกต้อง', + 'redis_question' => 'ติดตั้งคอมโพเนนต์ Redis webman/redis', + 'events_note' => ' (Redis ต้องการ illuminate/events รวมไว้โดยอัตโนมัติ)', + 'validation_question' => 'ติดตั้งคอมโพเนนต์ตรวจสอบ webman/validation', + 'template_question' => 'เทมเพลตเอนจิน', + 'template_none' => 'ไม่ติดตั้ง', + 'no_components' => 'ไม่ได้เลือกคอมโพเนนต์เสริม', + 'installing' => 'กำลังติดตั้ง:', + 'running' => 'กำลังดำเนินการ:', + 'error_install' => 'ติดตั้งล้มเหลว ลองด้วยตนเอง: composer require %s', + 'done' => 'คอมโพเนนต์เสริมติดตั้งเรียบร้อยแล้ว', + 'summary_locale' => 'ภาษา: %s', + 'summary_timezone' => 'เขตเวลา: %s', + ], + ]; + + // --- Interrupt message (Ctrl+C) --- + + private const INTERRUPTED_MESSAGES = [ + 'zh_CN' => '安装中断,可运行 composer setup-webman 可重新设置。', + 'zh_TW' => '安裝中斷,可運行 composer setup-webman 重新設置。', + 'en' => 'Setup interrupted. Run "composer setup-webman" to restart setup.', + 'ja' => 'セットアップが中断されました。composer setup-webman を実行して再設定できます。', + 'ko' => '설치가 중단되었습니다. composer setup-webman 을 실행하여 다시 설정할 수 있습니다.', + 'fr' => 'Installation interrompue. Exécutez « composer setup-webman » pour recommencer.', + 'de' => 'Einrichtung abgebrochen. Führen Sie "composer setup-webman" aus, um neu zu starten.', + 'es' => 'Instalación interrumpida. Ejecute "composer setup-webman" para reiniciar.', + 'pt_BR' => 'Instalação interrompida. Execute "composer setup-webman" para reiniciar.', + 'ru' => 'Установка прервана. Выполните «composer setup-webman» для повторной настройки.', + 'vi' => 'Cài đặt bị gián đoạn. Chạy "composer setup-webman" để cài đặt lại.', + 'tr' => 'Kurulum kesildi. Yeniden kurmak için "composer setup-webman" komutunu çalıştırın.', + 'id' => 'Instalasi terganggu. Jalankan "composer setup-webman" untuk mengatur ulang.', + 'th' => 'การติดตั้งถูกขัดจังหวะ เรียกใช้ "composer setup-webman" เพื่อตั้งค่าใหม่', + ]; + + // --- Signal handling state --- + + /** @var string|null Saved stty mode for terminal restoration on interrupt */ + private static ?string $sttyMode = null; + + /** @var string Current locale for interrupt message */ + private static string $interruptLocale = 'en'; + + // ═══════════════════════════════════════════════════════════════ + // Entry + // ═══════════════════════════════════════════════════════════════ + + public static function run(Event $event): void + { + $io = $event->getIO(); + + // Non-interactive mode: use English for skip message + if (!$io->isInteractive()) { + $io->write('' . self::MESSAGES['en']['skip'] . ''); + return; + } + + try { + self::doRun($event, $io); + } catch (\Throwable $e) { + $io->writeError(''); + $io->writeError('Setup wizard error: ' . $e->getMessage() . ''); + $io->writeError('Run "composer setup-webman" to retry.'); + } + } + + private static function doRun(Event $event, IOInterface $io): void + { + $io->write(''); + + // Register Ctrl+C handler + self::registerInterruptHandler(); + + // Banner title (must be before locale selection) + self::renderTitle(); + + // 1. Locale selection + $locale = self::askLocale($io); + self::$interruptLocale = $locale; + $defaultTimezone = self::LOCALE_DEFAULT_TIMEZONES[$locale] ?? 'UTC'; + $msg = fn(string $key, string ...$args): string => + empty($args) ? self::MESSAGES[$locale][$key] : sprintf(self::MESSAGES[$locale][$key], ...$args); + + // Write locale config (update when not default) + if ($locale !== 'zh_CN') { + self::updateConfig($event, 'config/translation.php', "'locale'", $locale); + } + + $io->write(''); + $io->write(''); + + // 2. Timezone selection (default by locale) + $timezone = self::askTimezone($io, $msg, $defaultTimezone); + if ($timezone !== 'Asia/Shanghai') { + self::updateConfig($event, 'config/app.php', "'default_timezone'", $timezone); + } + + // 3. Optional components + $packages = self::askComponents($io, $msg); + + // 4. Remove unselected components + $removePackages = self::askRemoveComponents($event, $packages, $io, $msg); + + // 5. Summary + $io->write(''); + $io->write('─────────────────────────────────────'); + $io->write('' . $msg('summary_locale', self::LOCALE_LABELS[$locale]) . ''); + $io->write('' . $msg('summary_timezone', $timezone) . ''); + + // Remove unselected packages first to avoid dependency conflicts + if ($removePackages !== []) { + $io->write(''); + $io->write('' . $msg('removing') . ''); + + $secondaryPackages = [ + self::PACKAGE_ILLUMINATE_EVENTS, + self::PACKAGE_ILLUMINATE_PAGINATION, + self::PACKAGE_SYMFONY_VAR_DUMPER, + ]; + $displayRemovePackages = array_diff($removePackages, $secondaryPackages); + foreach ($displayRemovePackages as $pkg) { + $io->write(' - ' . $pkg); + } + $io->write(''); + self::runComposerRemove($removePackages, $io, $msg); + } + + // Then install selected packages + if ($packages !== []) { + $io->write(''); + $io->write('' . $msg('installing') . ' ' . implode(', ', $packages)); + $io->write(''); + self::runComposerRequire($packages, $io, $msg); + } elseif ($removePackages === []) { + $io->write('' . $msg('no_components') . ''); + } + } + + private static function renderTitle(): void + { + $output = new ConsoleOutput(); + $terminalWidth = (new Terminal())->getWidth(); + if ($terminalWidth <= 0) { + $terminalWidth = 80; + } + + $text = ' ' . self::SETUP_TITLE . ' '; + $minBoxWidth = 44; + $maxBoxWidth = min($terminalWidth, 96); + $boxWidth = min($maxBoxWidth, max($minBoxWidth, mb_strwidth($text) + 10)); + + $innerWidth = $boxWidth - 2; + $textWidth = mb_strwidth($text); + $pad = max(0, $innerWidth - $textWidth); + $left = intdiv($pad, 2); + $right = $pad - $left; + $line2 = '│' . str_repeat(' ', $left) . $text . str_repeat(' ', $right) . '│'; + $line1 = '┌' . str_repeat('─', $innerWidth) . '┐'; + $line3 = '└' . str_repeat('─', $innerWidth) . '┘'; + + $output->writeln(''); + $output->writeln('' . $line1 . ''); + $output->writeln('' . $line2 . ''); + $output->writeln('' . $line3 . ''); + $output->writeln(''); + } + + // ═══════════════════════════════════════════════════════════════ + // Signal handling (Ctrl+C) + // ═══════════════════════════════════════════════════════════════ + + /** + * Register Ctrl+C (SIGINT) handler to show a friendly message on interrupt. + * Gracefully skipped when the required extensions are unavailable. + */ + private static function registerInterruptHandler(): void + { + // Unix/Linux/Mac: pcntl extension with async signals for immediate delivery + /*if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) { + pcntl_async_signals(true); + pcntl_signal(\SIGINT, [self::class, 'handleInterrupt']); + return; + }*/ + + // Windows: sapi ctrl handler (PHP >= 7.4) + if (function_exists('sapi_windows_set_ctrl_handler')) { + sapi_windows_set_ctrl_handler(static function (int $event) { + if ($event === \PHP_WINDOWS_EVENT_CTRL_C) { + self::handleInterrupt(); + } + }); + } + } + + /** + * Handle Ctrl+C: restore terminal, show tip, then exit. + */ + private static function handleInterrupt(): void + { + // Restore terminal if in raw mode + if (self::$sttyMode !== null && function_exists('shell_exec')) { + @shell_exec('stty ' . self::$sttyMode); + self::$sttyMode = null; + } + + $output = new ConsoleOutput(); + $output->writeln(''); + $output->writeln('' . (self::INTERRUPTED_MESSAGES[self::$interruptLocale] ?? self::INTERRUPTED_MESSAGES['en']) . ''); + exit(1); + } + + // ═══════════════════════════════════════════════════════════════ + // Interactive Menu System + // ═══════════════════════════════════════════════════════════════ + + /** + * Check if terminal supports interactive features (arrow keys, ANSI colors). + */ + private static function supportsInteractive(): bool + { + return function_exists('shell_exec') && Terminal::hasSttyAvailable(); + } + + /** + * Display a selection menu with arrow key navigation (if supported) or text input fallback. + * + * @param IOInterface $io Composer IO + * @param string $title Menu title + * @param array $items Indexed array of ['tag' => string, 'label' => string] + * @param int $default Default selected index (0-based) + * @return int Selected index + */ + private static function selectMenu(IOInterface $io, string $title, array $items, int $default = 0): int + { + // Append localized "default" hint to avoid ambiguity + // (Template should contain a single %s placeholder for the default tag.) + $defaultHintTemplate = null; + if (isset(self::MESSAGES[self::$interruptLocale]['default_choice'])) { + $defaultHintTemplate = self::MESSAGES[self::$interruptLocale]['default_choice']; + } + + $defaultTag = $items[$default]['tag'] ?? ''; + if ($defaultHintTemplate && $defaultTag !== '') { + $title .= sprintf($defaultHintTemplate, $defaultTag); + } elseif ($defaultTag !== '') { + // Fallback for early menus (e.g. locale selection) before locale is chosen. + $title .= sprintf(' (default %s)', $defaultTag); + } + + if (self::supportsInteractive()) { + return self::arrowKeySelect($title, $items, $default); + } + + return self::fallbackSelect($io, $title, $items, $default); + } + + /** + * Display a yes/no confirmation as a selection menu. + * + * @param IOInterface $io Composer IO + * @param string $title Menu title + * @param bool $default Default value (true = yes) + * @return bool User's choice + */ + private static function confirmMenu(IOInterface $io, string $title, bool $default = true): bool + { + $locale = self::$interruptLocale; + $yes = self::MESSAGES[$locale]['yes'] ?? self::MESSAGES['en']['yes'] ?? 'yes'; + $no = self::MESSAGES[$locale]['no'] ?? self::MESSAGES['en']['no'] ?? 'no'; + $items = $default + ? [['tag' => 'Y', 'label' => $yes], ['tag' => 'n', 'label' => $no]] + : [['tag' => 'y', 'label' => $yes], ['tag' => 'N', 'label' => $no]]; + $defaultIndex = $default ? 0 : 1; + + return self::selectMenu($io, $title, $items, $defaultIndex) === 0; + } + + /** + * Interactive select with arrow key navigation, manual input and ANSI reverse-video highlighting. + * Input area and option list highlighting are bidirectionally linked. + * Requires stty (Unix-like terminals). + */ + private static function arrowKeySelect(string $title, array $items, int $default): int + { + $output = new ConsoleOutput(); + $count = count($items); + $selected = $default; + + $maxTagWidth = max(array_map(fn(array $item) => mb_strlen($item['tag']), $items)); + $defaultTag = $items[$default]['tag']; + $input = $defaultTag; + + // Print title and initial options + $output->writeln(''); + $output->writeln('' . $title . ''); + self::drawMenuItems($output, $items, $selected, $maxTagWidth); + $output->write('> ' . $input); + + // Enter raw mode + self::$sttyMode = shell_exec('stty -g'); + shell_exec('stty -icanon -echo'); + + try { + while (!feof(STDIN)) { + $c = fread(STDIN, 1); + + if (false === $c || '' === $c) { + break; + } + + // ── Backspace ── + if ("\177" === $c || "\010" === $c) { + if ('' !== $input) { + $input = mb_substr($input, 0, -1); + } + $selected = self::findItemByTag($items, $input); + $output->write("\033[{$count}A"); + self::drawMenuItems($output, $items, $selected, $maxTagWidth); + $output->write("\033[2K\r> " . $input); + continue; + } + + // ── Escape sequences (arrow keys) ── + if ("\033" === $c) { + $seq = fread(STDIN, 2); + if (isset($seq[1])) { + $changed = false; + if ('A' === $seq[1]) { // Up + $selected = ($selected <= 0 ? $count : $selected) - 1; + $changed = true; + } elseif ('B' === $seq[1]) { // Down + $selected = ($selected + 1) % $count; + $changed = true; + } + if ($changed) { + // Sync input with selected item's tag + $input = $items[$selected]['tag']; + $output->write("\033[{$count}A"); + self::drawMenuItems($output, $items, $selected, $maxTagWidth); + $output->write("\033[2K\r> " . $input); + } + } + continue; + } + + // ── Enter: confirm selection ── + if ("\n" === $c || "\r" === $c) { + if ($selected < 0) { + $selected = $default; + } + $output->write("\033[2K\r> " . $items[$selected]['tag'] . ' ' . $items[$selected]['label'] . ''); + $output->writeln(''); + break; + } + + // ── Ignore other control characters ── + if (ord($c) < 32) { + continue; + } + + // ── Printable character (with UTF-8 multi-byte support) ── + if ("\x80" <= $c) { + $extra = ["\xC0" => 1, "\xD0" => 1, "\xE0" => 2, "\xF0" => 3]; + $c .= fread(STDIN, $extra[$c & "\xF0"] ?? 0); + } + $input .= $c; + $selected = self::findItemByTag($items, $input); + $output->write("\033[{$count}A"); + self::drawMenuItems($output, $items, $selected, $maxTagWidth); + $output->write("\033[2K\r> " . $input); + } + } finally { + if (self::$sttyMode !== null) { + shell_exec('stty ' . self::$sttyMode); + self::$sttyMode = null; + } + } + + return $selected < 0 ? $default : $selected; + } + + /** + * Fallback select for terminals without stty support. Uses plain text input. + */ + private static function fallbackSelect(IOInterface $io, string $title, array $items, int $default): int + { + $maxTagWidth = max(array_map(fn(array $item) => mb_strlen($item['tag']), $items)); + $defaultTag = $items[$default]['tag']; + + $io->write(''); + $io->write('' . $title . ''); + foreach ($items as $item) { + $tag = str_pad($item['tag'], $maxTagWidth); + $io->write(" [$tag] " . $item['label']); + } + + while (true) { + $io->write('> ', false); + $line = fgets(STDIN); + if ($line === false) { + return $default; + } + $answer = trim($line); + + if ($answer === '') { + $io->write('> ' . $items[$default]['tag'] . ' ' . $items[$default]['label'] . ''); + return $default; + } + + // Match by tag (case-insensitive) + foreach ($items as $i => $item) { + if (strcasecmp($item['tag'], $answer) === 0) { + $io->write('> ' . $items[$i]['tag'] . ' ' . $items[$i]['label'] . ''); + return $i; + } + } + } + } + + /** + * Render menu items with optional ANSI reverse-video highlighting for the selected item. + * When $selected is -1, no item is highlighted. + */ + private static function drawMenuItems(ConsoleOutput $output, array $items, int $selected, int $maxTagWidth): void + { + foreach ($items as $i => $item) { + $tag = str_pad($item['tag'], $maxTagWidth); + $line = " [$tag] " . $item['label']; + if ($i === $selected) { + $output->writeln("\033[2K\r\033[7m" . $line . "\033[0m"); + } else { + $output->writeln("\033[2K\r" . $line); + } + } + } + + /** + * Find item index by tag (case-insensitive exact match). + * Returns -1 if no match found or input is empty. + */ + private static function findItemByTag(array $items, string $input): int + { + if ($input === '') { + return -1; + } + foreach ($items as $i => $item) { + if (strcasecmp($item['tag'], $input) === 0) { + return $i; + } + } + return -1; + } + + // ═══════════════════════════════════════════════════════════════ + // Locale selection + // ═══════════════════════════════════════════════════════════════ + + private static function askLocale(IOInterface $io): string + { + $locales = array_keys(self::LOCALE_LABELS); + $items = []; + foreach ($locales as $i => $code) { + $items[] = ['tag' => (string) $i, 'label' => self::LOCALE_LABELS[$code] . " ($code)"]; + } + + $selected = self::selectMenu( + $io, + '语言 / Language / 言語 / 언어', + $items, + 0 + ); + + return $locales[$selected]; + } + + // ═══════════════════════════════════════════════════════════════ + // Timezone selection + // ═══════════════════════════════════════════════════════════════ + + private static function askTimezone(IOInterface $io, callable $msg, string $default): string + { + if (self::supportsInteractive()) { + return self::askTimezoneAutocomplete($msg, $default); + } + + return self::askTimezoneSelect($io, $msg, $default); + } + + /** + * Option A: when stty is available, custom character-by-character autocomplete + * (case-insensitive, substring match). Interaction: type to filter, hint on right; + * ↑↓ change candidate, Tab accept, Enter confirm; empty input = use default. + */ + private static function askTimezoneAutocomplete(callable $msg, string $default): string + { + $allTimezones = \DateTimeZone::listIdentifiers(); + $output = new ConsoleOutput(); + $cursor = new Cursor($output); + + $output->writeln(''); + $output->writeln('' . $msg('timezone_title', $default) . ''); + $output->writeln($msg('timezone_help')); + $output->write('> '); + + self::$sttyMode = shell_exec('stty -g'); + shell_exec('stty -icanon -echo'); + + // Auto-fill default timezone in the input area; user can edit it directly. + $input = $default; + $output->write($input); + + $ofs = 0; + $matches = self::filterTimezones($allTimezones, $input); + if (!empty($matches)) { + $hint = $matches[$ofs % count($matches)]; + // Avoid duplicating hint when input already fully matches the only candidate. + if (!(count($matches) === 1 && $hint === $input)) { + $cursor->clearLineAfter(); + $cursor->savePosition(); + $output->write(' ' . $hint . ''); + if (count($matches) > 1) { + $output->write(' (' . count($matches) . ' matches, ↑↓)'); + } + $cursor->restorePosition(); + } + } + + try { + while (!feof(STDIN)) { + $c = fread(STDIN, 1); + + if (false === $c || '' === $c) { + break; + } + + // ── Backspace ── + if ("\177" === $c || "\010" === $c) { + if ('' !== $input) { + $lastChar = mb_substr($input, -1); + $input = mb_substr($input, 0, -1); + $cursor->moveLeft(max(1, mb_strwidth($lastChar))); + } + $ofs = 0; + + // ── Escape sequences (arrows) ── + } elseif ("\033" === $c) { + $seq = fread(STDIN, 2); + if (isset($seq[1]) && !empty($matches)) { + if ('A' === $seq[1]) { + $ofs = ($ofs - 1 + count($matches)) % count($matches); + } elseif ('B' === $seq[1]) { + $ofs = ($ofs + 1) % count($matches); + } + } + + // ── Tab: accept current match ── + } elseif ("\t" === $c) { + if (isset($matches[$ofs])) { + self::replaceInput($output, $cursor, $input, $matches[$ofs]); + $input = $matches[$ofs]; + $matches = []; + } + $cursor->clearLineAfter(); + continue; + + // ── Enter: confirm ── + } elseif ("\n" === $c || "\r" === $c) { + if (isset($matches[$ofs])) { + self::replaceInput($output, $cursor, $input, $matches[$ofs]); + $input = $matches[$ofs]; + } + if ($input === '') { + $input = $default; + } + // Re-render user input with style + $cursor->moveToColumn(1); + $cursor->clearLine(); + $output->write('> ' . $input . ''); + $output->writeln(''); + break; + + // ── Other control chars: ignore ── + } elseif (ord($c) < 32) { + continue; + + // ── Printable character ── + } else { + if ("\x80" <= $c) { + $extra = ["\xC0" => 1, "\xD0" => 1, "\xE0" => 2, "\xF0" => 3]; + $c .= fread(STDIN, $extra[$c & "\xF0"] ?? 0); + } + $output->write($c); + $input .= $c; + $ofs = 0; + } + + // Update match list + $matches = self::filterTimezones($allTimezones, $input); + + // Show autocomplete hint + $cursor->clearLineAfter(); + if (!empty($matches)) { + $hint = $matches[$ofs % count($matches)]; + $cursor->savePosition(); + $output->write(' ' . $hint . ''); + if (count($matches) > 1) { + $output->write(' (' . count($matches) . ' matches, ↑↓)'); + } + $cursor->restorePosition(); + } + } + } finally { + if (self::$sttyMode !== null) { + shell_exec('stty ' . self::$sttyMode); + self::$sttyMode = null; + } + } + + $result = '' === $input ? $default : $input; + + if (!in_array($result, $allTimezones, true)) { + $output->writeln('' . $msg('timezone_invalid', $default) . ''); + return $default; + } + + return $result; + } + + /** + * Clear current input and replace with new text. + */ + private static function replaceInput(ConsoleOutput $output, Cursor $cursor, string $oldInput, string $newInput): void + { + if ('' !== $oldInput) { + $cursor->moveLeft(mb_strwidth($oldInput)); + } + $cursor->clearLineAfter(); + $output->write($newInput); + } + + /** + * Case-insensitive substring match for timezones. + */ + private static function filterTimezones(array $timezones, string $input): array + { + if ('' === $input) { + return []; + } + $lower = mb_strtolower($input); + return array_values(array_filter( + $timezones, + fn(string $tz) => str_contains(mb_strtolower($tz), $lower) + )); + } + + /** + * Find an exact timezone match (case-insensitive). + * Returns the correctly-cased system timezone name, or null if not found. + */ + private static function findExactTimezone(array $allTimezones, string $input): ?string + { + $lower = mb_strtolower($input); + foreach ($allTimezones as $tz) { + if (mb_strtolower($tz) === $lower) { + return $tz; + } + } + return null; + } + + /** + * Search timezones by keyword (substring) and similarity. + * Returns combined results: substring matches first, then similarity matches (>=50%). + * + * @param string[] $allTimezones All valid timezone identifiers + * @param string $keyword User input to search for + * @param int $limit Maximum number of results + * @return string[] Matched timezone identifiers + */ + private static function searchTimezones(array $allTimezones, string $keyword, int $limit = 15): array + { + // 1. Substring matches (higher priority) + $substringMatches = self::filterTimezones($allTimezones, $keyword); + if (count($substringMatches) >= $limit) { + return array_slice($substringMatches, 0, $limit); + } + + // 2. Similarity matches for remaining slots (normalized: strip _ and /) + $substringSet = array_flip($substringMatches); + $normalizedKeyword = str_replace(['_', '/'], ' ', mb_strtolower($keyword)); + $similarityMatches = []; + + foreach ($allTimezones as $tz) { + if (isset($substringSet[$tz])) { + continue; + } + $parts = explode('/', $tz); + $city = str_replace('_', ' ', mb_strtolower(end($parts))); + $normalizedTz = str_replace(['_', '/'], ' ', mb_strtolower($tz)); + + similar_text($normalizedKeyword, $city, $cityPercent); + similar_text($normalizedKeyword, $normalizedTz, $fullPercent); + + $bestPercent = max($cityPercent, $fullPercent); + if ($bestPercent >= 50.0) { + $similarityMatches[] = ['tz' => $tz, 'score' => $bestPercent]; + } + } + + usort($similarityMatches, fn(array $a, array $b) => $b['score'] <=> $a['score']); + + $results = $substringMatches; + foreach ($similarityMatches as $item) { + $results[] = $item['tz']; + if (count($results) >= $limit) { + break; + } + } + + return $results; + } + + /** + * Option B: when stty is not available (e.g. Windows), keyword search with numbered list. + * Flow: enter timezone/keyword → exact match uses it directly; otherwise show + * numbered results (substring + similarity) → pick by number or refine keyword. + */ + private static function askTimezoneSelect(IOInterface $io, callable $msg, string $default): string + { + $allTimezones = \DateTimeZone::listIdentifiers(); + + $io->write(''); + $io->write('' . $msg('timezone_title', $default) . ''); + $io->write($msg('timezone_input_prompt')); + + /** @var string[]|null Currently displayed search result list */ + $currentList = null; + + while (true) { + $io->write('> ', false); + $line = fgets(STDIN); + if ($line === false) { + return $default; + } + $answer = trim($line); + + // Empty input → use default + if ($answer === '') { + $io->write('> ' . $default . ''); + return $default; + } + + // If a numbered list is displayed and input is a pure number + if ($currentList !== null && ctype_digit($answer)) { + $idx = (int) $answer; + if (isset($currentList[$idx])) { + $io->write('> ' . $currentList[$idx] . ''); + return $currentList[$idx]; + } + $io->write('' . $msg('timezone_invalid_index') . ''); + continue; + } + + // Exact case-insensitive match → return the correctly-cased system value + $exact = self::findExactTimezone($allTimezones, $answer); + if ($exact !== null) { + $io->write('> ' . $exact . ''); + return $exact; + } + + // Keyword + similarity search + $results = self::searchTimezones($allTimezones, $answer); + + if (empty($results)) { + $io->write('' . $msg('timezone_no_match') . ''); + $currentList = null; + continue; + } + + // Single result → use it directly + if (count($results) === 1) { + $io->write('> ' . $results[0] . ''); + return $results[0]; + } + + // Display numbered list + $currentList = $results; + $padWidth = strlen((string) (count($results) - 1)); + foreach ($results as $i => $tz) { + $io->write(' [' . str_pad((string) $i, $padWidth) . '] ' . $tz); + } + $io->write($msg('timezone_pick_prompt')); + } + } + + // ═══════════════════════════════════════════════════════════════ + // Optional component selection + // ═══════════════════════════════════════════════════════════════ + + private static function askComponents(IOInterface $io, callable $msg): array + { + $packages = []; + $addPackage = static function (string $package) use (&$packages, $io, $msg): void { + if (in_array($package, $packages, true)) { + return; + } + $packages[] = $package; + $io->write($msg('adding_package', '' . $package . '')); + }; + + // Console (default: yes) + if (self::confirmMenu($io, $msg('console_question'), true)) { + $addPackage(self::PACKAGE_CONSOLE); + } + + // Database + $dbItems = [ + ['tag' => '0', 'label' => $msg('db_none')], + ['tag' => '1', 'label' => 'webman/database'], + ['tag' => '2', 'label' => 'webman/think-orm'], + ['tag' => '3', 'label' => 'webman/database && webman/think-orm'], + ]; + $dbChoice = self::selectMenu($io, $msg('db_question'), $dbItems, 0); + if ($dbChoice === 1) { + $addPackage(self::PACKAGE_DATABASE); + } elseif ($dbChoice === 2) { + $addPackage(self::PACKAGE_THINK_ORM); + } elseif ($dbChoice === 3) { + $addPackage(self::PACKAGE_DATABASE); + $addPackage(self::PACKAGE_THINK_ORM); + } + + // If webman/database is selected, add required dependencies automatically + if (in_array(self::PACKAGE_DATABASE, $packages, true)) { + $addPackage(self::PACKAGE_ILLUMINATE_PAGINATION); + $addPackage(self::PACKAGE_ILLUMINATE_EVENTS); + $addPackage(self::PACKAGE_SYMFONY_VAR_DUMPER); + } + + // Redis (default: no) + if (self::confirmMenu($io, $msg('redis_question'), false)) { + $addPackage(self::PACKAGE_REDIS); + $addPackage(self::PACKAGE_ILLUMINATE_EVENTS); + } + + // Validation (default: no) + if (self::confirmMenu($io, $msg('validation_question'), false)) { + $addPackage(self::PACKAGE_VALIDATION); + } + + // Template engine + $tplItems = [ + ['tag' => '0', 'label' => $msg('template_none')], + ['tag' => '1', 'label' => 'webman/blade'], + ['tag' => '2', 'label' => 'twig/twig'], + ['tag' => '3', 'label' => 'topthink/think-template'], + ]; + $tplChoice = self::selectMenu($io, $msg('template_question'), $tplItems, 0); + if ($tplChoice === 1) { + $addPackage(self::PACKAGE_BLADE); + } elseif ($tplChoice === 2) { + $addPackage(self::PACKAGE_TWIG); + } elseif ($tplChoice === 3) { + $addPackage(self::PACKAGE_THINK_TEMPLATE); + } + + return $packages; + } + + // ═══════════════════════════════════════════════════════════════ + // Config file update + // ═══════════════════════════════════════════════════════════════ + + /** + * Update a config value like 'key' => 'old_value' in the given file. + */ + private static function updateConfig(Event $event, string $relativePath, string $key, string $newValue): void + { + $root = dirname($event->getComposer()->getConfig()->get('vendor-dir')); + $file = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath); + if (!is_readable($file)) { + return; + } + $content = file_get_contents($file); + if ($content === false) { + return; + } + $pattern = '/' . preg_quote($key, '/') . "\s*=>\s*'[^']*'/"; + $replacement = $key . " => '" . $newValue . "'"; + $newContent = preg_replace($pattern, $replacement, $content); + if ($newContent !== null && $newContent !== $content) { + file_put_contents($file, $newContent); + } + } + + // ═══════════════════════════════════════════════════════════════ + // Composer require + // ═══════════════════════════════════════════════════════════════ + + private static function runComposerRequire(array $packages, IOInterface $io, callable $msg): void + { + $io->write('' . $msg('running') . ' composer require ' . implode(' ', $packages)); + $io->write(''); + + $code = self::runComposerCommand('require', $packages); + + if ($code !== 0) { + $io->writeError('' . $msg('error_install', implode(' ', $packages)) . ''); + } else { + $io->write('' . $msg('done') . ''); + } + } + + private static function askRemoveComponents(Event $event, array $selectedPackages, IOInterface $io, callable $msg): array + { + $requires = $event->getComposer()->getPackage()->getRequires(); + $allOptionalPackages = [ + self::PACKAGE_CONSOLE, + self::PACKAGE_DATABASE, + self::PACKAGE_THINK_ORM, + self::PACKAGE_REDIS, + self::PACKAGE_ILLUMINATE_EVENTS, + self::PACKAGE_ILLUMINATE_PAGINATION, + self::PACKAGE_SYMFONY_VAR_DUMPER, + self::PACKAGE_VALIDATION, + self::PACKAGE_BLADE, + self::PACKAGE_TWIG, + self::PACKAGE_THINK_TEMPLATE, + ]; + + $secondaryPackages = [ + self::PACKAGE_ILLUMINATE_EVENTS, + self::PACKAGE_ILLUMINATE_PAGINATION, + self::PACKAGE_SYMFONY_VAR_DUMPER, + ]; + + $installedOptionalPackages = []; + foreach ($allOptionalPackages as $pkg) { + if (isset($requires[$pkg])) { + $installedOptionalPackages[] = $pkg; + } + } + + $allPackagesToRemove = array_diff($installedOptionalPackages, $selectedPackages); + + if (count($allPackagesToRemove) === 0) { + return []; + } + + $displayPackagesToRemove = array_diff($allPackagesToRemove, $secondaryPackages); + + if (count($displayPackagesToRemove) === 0) { + return $allPackagesToRemove; + } + + $pkgListStr = ""; + foreach ($displayPackagesToRemove as $pkg) { + $pkgListStr .= "\n - {$pkg}"; + } + $pkgListStr .= "\n"; + + $title = '' . $msg('remove_package_question', '') . '' . $pkgListStr; + if (self::confirmMenu($io, $title, false)) { + return $allPackagesToRemove; + } + + return []; + } + + private static function runComposerRemove(array $packages, IOInterface $io, callable $msg): void + { + $io->write('' . $msg('running') . ' composer remove ' . implode(' ', $packages)); + $io->write(''); + + $code = self::runComposerCommand('remove', $packages); + + if ($code !== 0) { + $io->writeError('' . $msg('error_remove', implode(' ', $packages)) . ''); + } else { + $io->write('' . $msg('done_remove') . ''); + } + } + + /** + * Run a Composer command (require/remove) in-process via Composer's Application API. + * No shell execution functions needed — works even when passthru/exec/shell_exec are disabled. + */ + private static function runComposerCommand(string $command, array $packages): int + { + try { + // Already inside a user-initiated Composer session — suppress duplicate root/superuser warnings + $_SERVER['COMPOSER_ALLOW_SUPERUSER'] = '1'; + if (function_exists('putenv')) { + putenv('COMPOSER_ALLOW_SUPERUSER=1'); + } + + $application = new ComposerApplication(); + $application->setAutoExit(false); + + return $application->run( + new ArrayInput([ + 'command' => $command, + 'packages' => $packages, + '--no-interaction' => true, + '--update-with-all-dependencies' => true, + ]), + new ConsoleOutput() + ); + } catch (\Throwable) { + return 1; + } + } +} diff --git a/support/functions.php b/support/functions.php new file mode 100644 index 0000000..a72c068 --- /dev/null +++ b/support/functions.php @@ -0,0 +1,52 @@ +get($key, $default, $refresh); + } +} diff --git a/support/helpers.php b/support/helpers.php deleted file mode 100644 index f011f1b..0000000 --- a/support/helpers.php +++ /dev/null @@ -1,59 +0,0 @@ -getValue($key, $default); - } -} diff --git a/test.php b/test.php deleted file mode 100644 index acb08a0..0000000 --- a/test.php +++ /dev/null @@ -1,14 +0,0 @@ -getTabs(); -echo json_encode($tabs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); \ No newline at end of file