From 30198c7191c15bf6d838415e27f3e2bf59bed915 Mon Sep 17 00:00:00 2001 From: zhaogd Date: Sun, 26 Jul 2026 12:34:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E5=A4=A9=E5=8D=A1?= =?UTF-8?q?=E5=BC=80=E6=94=BE=E5=B9=B3=E5=8F=B0=E5=90=8E=E7=AB=AF=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 包含完整功能模块: - 设备激活(直销激活码模式 + 代销免激活码模式) - 微信支付 + 天卡订单 - 天卡池管理 + 异步橘视厅SDK激活 - 第三方合作伙伴接口(代销模式) - 签名鉴权(appKey/appSecret + nonce防重放) - 天卡消耗(事务 + 幂等 + 并发控制) - 媒资接口(三级授权过滤) - 审计日志 - 定价策略(3层:门店 > 渠道 > 平台) - 分账(微信分账 + 分润规则) - Docker部署(含Redis容器) --- .gitignore | 30 + .trae/rules/git-commit-message.md | 7 + API_DOC.md | 388 +++ Dockerfile | 13 + HANDOVER.md | 95 + db/add_device_source_field.sql | 7 + db/audit_log_rebuild.sql | 22 + db/device-info-fields.sql | 12 + db/device-tables.sql | 51 + db/full-schema.sql | 1013 +++++++ db/init-sys-data.sql | 63 + db/mysql.sql | 146 + db/openapi_media.sql | 31 + db/openapi_partner_card_pool.sql | 20 + db/price-config-3layer.sql | 13 + db/restore_test_card_and_jst_config.sql | 61 + db/schedule-timeout-task.sql | 21 + docker-compose.yml | 42 + pom.xml | 270 ++ src/main/java/io/as/AsApplication.java | 18 + .../io/as/common/annotation/AuditAction.java | 22 + .../java/io/as/common/annotation/SysLog.java | 15 + .../io/as/common/aspect/ApiLogAspect.java | 121 + .../io/as/common/aspect/AuditLogAspect.java | 128 + .../java/io/as/common/aspect/RedisAspect.java | 38 + .../io/as/common/aspect/SysLogAspect.java | 1 + .../io/as/common/exception/ASException.java | 52 + .../common/exception/ASExceptionHandler.java | 66 + .../io/as/common/utils/BeanFindUtils.java | 65 + .../io/as/common/utils/ConfigConstant.java | 18 + .../java/io/as/common/utils/Constant.java | 146 + .../utils/CustomHeaderCaptureUtils.java | 70 + .../java/io/as/common/utils/DateUtils.java | 158 ++ .../io/as/common/utils/HttpContextUtils.java | 24 + src/main/java/io/as/common/utils/IPUtils.java | 56 + .../io/as/common/utils/MacAddressUtils.java | 151 + .../java/io/as/common/utils/MapUtils.java | 18 + .../java/io/as/common/utils/PageUtils.java | 102 + src/main/java/io/as/common/utils/Query.java | 69 + src/main/java/io/as/common/utils/R.java | 99 + .../java/io/as/common/utils/RedisKeys.java | 13 + .../java/io/as/common/utils/RedisUtils.java | 112 + src/main/java/io/as/common/utils/RsaUtil.java | 160 ++ .../java/io/as/common/utils/ShiroUtils.java | 53 + .../as/common/utils/SpringContextUtils.java | 47 + .../java/io/as/common/validator/Assert.java | 24 + .../as/common/validator/ValidatorUtils.java | 46 + .../as/common/validator/group/AddGroup.java | 9 + .../io/as/common/validator/group/Group.java | 13 + .../common/validator/group/QcloudGroup.java | 9 + .../common/validator/group/UpdateGroup.java | 11 + .../java/io/as/common/xss/HTMLFilter.java | 530 ++++ src/main/java/io/as/common/xss/SQLFilter.java | 42 + src/main/java/io/as/common/xss/XssFilter.java | 37 + .../xss/XssHttpServletRequestWrapper.java | 139 + src/main/java/io/as/config/CorsConfig.java | 36 + src/main/java/io/as/config/FilterConfig.java | 41 + src/main/java/io/as/config/KaptchaConfig.java | 25 + .../java/io/as/config/MybatisPlusConfig.java | 23 + src/main/java/io/as/config/RedisConfig.java | 55 + src/main/java/io/as/config/ShiroConfig.java | 74 + .../java/io/as/config/SimpleCorsFilter.java | 49 + src/main/java/io/as/config/SwaggerConfig.java | 53 + .../as/datasource/annotation/DataSource.java | 16 + .../datasource/aspect/DataSourceAspect.java | 63 + .../config/DynamicContextHolder.java | 49 + .../datasource/config/DynamicDataSource.java | 17 + .../config/DynamicDataSourceConfig.java | 55 + .../config/DynamicDataSourceFactory.java | 46 + .../properties/DataSourceProperties.java | 194 ++ .../DynamicDataSourceProperties.java | 25 + .../daycard/constants/DayCardConstants.java | 166 ++ .../DayCardActivationCodeController.java | 165 ++ .../controller/DayCardManageController.java | 192 ++ .../controller/DayCardOrderController.java | 73 + .../DayCardPriceConfigController.java | 129 + .../controller/DayCardRefundController.java | 75 + .../controller/DayCardSettleController.java | 192 ++ .../OrgSettlementAccountController.java | 98 + .../ProfitSharingQueryController.java | 187 ++ .../ProfitSharingRuleController.java | 252 ++ .../daycard/dao/DayCardActivationCodeDao.java | 12 + .../daycard/dao/DayCardImportBatchDao.java | 12 + .../modules/daycard/dao/DayCardOrderDao.java | 12 + .../modules/daycard/dao/DayCardPoolDao.java | 22 + .../daycard/dao/DayCardPriceConfigDao.java | 12 + .../daycard/dao/DayCardProfitReceiverDao.java | 12 + .../daycard/dao/DayCardProfitRuleDao.java | 12 + .../modules/daycard/dao/DayCardRefundDao.java | 12 + .../daycard/dao/DayCardSettleOrderDao.java | 12 + .../daycard/dao/DayCardSettleRecordDao.java | 12 + .../daycard/dao/DayCardSettleRuleDao.java | 12 + .../daycard/dao/OrgSettlementAccountDao.java | 12 + .../daycard/dao/ProfitSharingDetailDao.java | 12 + .../daycard/dao/ProfitSharingOrderDao.java | 12 + .../daycard/dao/ProfitSharingRuleDao.java | 12 + .../daycard/dao/ProfitSharingRuleItemDao.java | 12 + .../entity/DayCardActivationCodeEntity.java | 52 + .../entity/DayCardImportBatchEntity.java | 46 + .../daycard/entity/DayCardOrderEntity.java | 82 + .../daycard/entity/DayCardPoolEntity.java | 59 + .../entity/DayCardPriceConfigEntity.java | 41 + .../entity/DayCardProfitReceiverEntity.java | 42 + .../entity/DayCardProfitRuleEntity.java | 53 + .../daycard/entity/DayCardRefundEntity.java | 46 + .../entity/DayCardSettleOrderEntity.java | 54 + .../entity/DayCardSettleRecordEntity.java | 46 + .../entity/DayCardSettleRuleEntity.java | 47 + .../entity/OrgSettlementAccountEntity.java | 47 + .../entity/ProfitSharingDetailEntity.java | 49 + .../entity/ProfitSharingOrderEntity.java | 52 + .../entity/ProfitSharingRuleEntity.java | 66 + .../entity/ProfitSharingRuleItemEntity.java | 46 + .../form/DayCardOrderCreateRequest.java | 22 + .../service/DayCardActivationCodeService.java | 68 + .../daycard/service/DayCardOrderService.java | 119 + .../daycard/service/DayCardPoolService.java | 49 + .../service/DayCardPriceConfigService.java | 40 + .../service/DayCardProfitReceiverService.java | 45 + .../service/DayCardProfitRuleService.java | 50 + .../daycard/service/DayCardRefundService.java | 45 + .../service/DayCardSettleOrderService.java | 50 + .../service/DayCardSettleRecordService.java | 28 + .../service/DayCardSettleRuleService.java | 50 + .../service/OrgSettlementAccountService.java | 43 + .../daycard/service/ProfitCalcService.java | 34 + .../service/ProfitSharingExecuteService.java | 19 + .../service/ProfitSharingRuleService.java | 48 + .../daycard/service/SettleExecuteService.java | 31 + .../DayCardActivationCodeServiceImpl.java | 174 ++ .../service/impl/DayCardOrderServiceImpl.java | 802 ++++++ .../service/impl/DayCardPoolServiceImpl.java | 207 ++ .../impl/DayCardPriceConfigServiceImpl.java | 96 + .../DayCardProfitReceiverServiceImpl.java | 107 + .../impl/DayCardProfitRuleServiceImpl.java | 156 ++ .../impl/DayCardRefundServiceImpl.java | 301 ++ .../impl/DayCardSettleOrderServiceImpl.java | 100 + .../impl/DayCardSettleRecordServiceImpl.java | 54 + .../impl/DayCardSettleRuleServiceImpl.java | 113 + .../impl/OrgSettlementAccountServiceImpl.java | 188 ++ .../service/impl/ProfitCalcServiceImpl.java | 126 + .../impl/ProfitSharingExecuteServiceImpl.java | 465 ++++ .../impl/ProfitSharingRuleServiceImpl.java | 217 ++ .../impl/SettleExecuteServiceImpl.java | 133 + .../task/DayCardActivateRetryTask.java | 32 + .../daycard/task/DayCardOrderTimeoutTask.java | 32 + .../daycard/task/ProfitSharingQueryTask.java | 32 + .../as/modules/daycard/vo/DayCardOrderVo.java | 27 + .../daycard/vo/DayCardPoolStatsVo.java | 22 + .../device/constants/DeviceConstants.java | 78 + .../device/controller/DeviceController.java | 94 + .../device/dao/CardDeviceBindingDao.java | 9 + .../io/as/modules/device/dao/DeviceDao.java | 59 + .../modules/device/dto/DeviceOrgContext.java | 26 + .../entity/CardDeviceBindingEntity.java | 35 + .../modules/device/entity/DeviceEntity.java | 106 + .../device/service/DeviceResolveService.java | 28 + .../modules/device/service/DeviceService.java | 58 + .../impl/DeviceResolveServiceImpl.java | 343 +++ .../service/impl/DeviceServiceImpl.java | 89 + .../as/modules/job/config/ScheduleConfig.java | 58 + .../io/as/modules/job/dao/ScheduleJobDao.java | 21 + .../as/modules/job/dao/ScheduleJobLogDao.java | 15 + .../io/as/modules/job/dto/JobIdsRequest.java | 13 + .../job/dto/ScheduleJobIdsDeserializer.java | 58 + .../modules/job/entity/ScheduleJobEntity.java | 66 + .../job/entity/ScheduleJobLogEntity.java | 63 + .../job/service/ScheduleJobLogService.java | 18 + .../job/service/ScheduleJobService.java | 52 + .../impl/ScheduleJobLogServiceImpl.java | 31 + .../service/impl/ScheduleJobServiceImpl.java | 123 + .../java/io/as/modules/job/task/ITask.java | 16 + .../io/as/modules/job/utils/ScheduleJob.java | 73 + .../as/modules/job/utils/ScheduleUtils.java | 148 + .../io/as/modules/jst/config/AsyncConfig.java | 30 + .../modules/jst/config/JstPlatformConfig.java | 34 + .../as/modules/jst/dao/ApiJstCallLogDao.java | 31 + .../jst/entity/ApiJstCallLogEntity.java | 46 + .../jst/service/ApiJstCallLogService.java | 32 + .../jst/service/JstActivateCardResult.java | 48 + .../jst/service/JushitingApiService.java | 39 + .../impl/ApiJstCallLogServiceImpl.java | 140 + .../service/impl/JushitingApiServiceImpl.java | 391 +++ .../media/client/JetsenMediaClient.java | 183 ++ .../media/config/JetsenMediaConfig.java | 41 + .../controller/JetsenMediaController.java | 232 ++ .../media/controller/MediaController.java | 52 + .../modules/media/service/MediaService.java | 35 + .../media/service/impl/MediaServiceImpl.java | 99 + .../as/modules/openapi/config/ApiConfig.java | 46 + .../modules/openapi/config/OpenApiConfig.java | 48 + .../openapi/constants/OpenApiConstants.java | 16 + .../openapi/controller/ApiJstController.java | 418 +++ .../controller/ApiJstUiController.java | 253 ++ .../controller/ContentPackageController.java | 219 ++ .../controller/OpenApiCallLogController.java | 36 + .../controller/OpenApiMediaController.java | 286 ++ .../OpenApiPartnerCardController.java | 100 + .../controller/OpenApiPartnerController.java | 112 + .../PartnerCardPurchaseController.java | 90 + .../openapi/dao/ContentPackageDao.java | 12 + .../openapi/dao/OpenApiCallLogDao.java | 12 + .../openapi/dao/OpenApiPartnerDao.java | 12 + .../openapi/dao/PartnerCardPoolDao.java | 12 + .../openapi/entity/ContentPackageEntity.java | 37 + .../openapi/entity/OpenApiCallLogEntity.java | 39 + .../openapi/entity/OpenApiPartnerEntity.java | 41 + .../openapi/entity/PartnerCardPoolEntity.java | 49 + .../openapi/filter/ApiDeviceSignFilter.java | 100 + .../openapi/filter/ApiJstCallLogFilter.java | 1 + .../ByteArrayReplayingHttpServletRequest.java | 1 + .../openapi/filter/PartnerSignFilter.java | 132 + .../openapi/form/DeviceInitRequest.java | 53 + .../service/ContentPackageService.java | 36 + .../service/OpenApiCallLogService.java | 35 + .../service/OpenApiPartnerService.java | 29 + .../service/PartnerCardPoolService.java | 88 + .../impl/ContentPackageServiceImpl.java | 65 + .../impl/OpenApiCallLogServiceImpl.java | 53 + .../impl/OpenApiPartnerServiceImpl.java | 49 + .../impl/PartnerCardPoolServiceImpl.java | 442 +++ .../openapi/sign/ApiDeviceSignService.java | 41 + .../modules/openapi/sign/DeviceSignCache.java | 22 + .../openapi/sign/DeviceSignResult.java | 18 + .../sign/impl/ApiDeviceSignServiceImpl.java | 138 + .../payment/config/WechatPayConfig.java | 27 + .../payment/controller/PaymentController.java | 283 ++ .../ProfitSharingCallbackController.java | 198 ++ .../controller/SettleCallbackController.java | 150 + .../controller/WechatPayConfigController.java | 83 + .../payment/dao/WechatPayConfigDao.java | 12 + .../payment/entity/WechatPayConfigEntity.java | 60 + .../payment/form/PaymentCreateRequest.java | 26 + .../service/WechatPayConfigService.java | 58 + .../payment/service/WechatPayResult.java | 42 + .../payment/service/WechatPayService.java | 121 + .../impl/WechatPayConfigServiceImpl.java | 101 + .../service/impl/WechatPayServiceImpl.java | 610 +++++ .../stats/controller/StatsController.java | 79 + .../modules/stats/service/StatsService.java | 41 + .../stats/service/impl/StatsServiceImpl.java | 133 + .../sys/constants/OrgCodeConstants.java | 95 + .../sys/constants/OrgTypeRoleConstants.java | 52 + .../sys/controller/AbstractController.java | 18 + .../sys/controller/ContractController.java | 84 + .../sys/controller/StoreController.java | 77 + .../sys/controller/SysLoginController.java | 70 + .../sys/controller/SysMenuController.java | 135 + .../sys/controller/SysOrgController.java | 273 ++ .../sys/controller/SysRoleController.java | 77 + .../sys/controller/SysUserController.java | 130 + .../io/as/modules/sys/dao/AuditLogDao.java | 12 + .../java/io/as/modules/sys/dao/CardDao.java | 9 + .../as/modules/sys/dao/CardTransferDao.java | 9 + .../sys/dao/ContractActivationCodeDao.java | 9 + .../io/as/modules/sys/dao/ContractDao.java | 9 + .../as/modules/sys/dao/StoreProfileDao.java | 12 + .../io/as/modules/sys/dao/SysCaptchaDao.java | 9 + .../io/as/modules/sys/dao/SysConfigDao.java | 27 + .../io/as/modules/sys/dao/SysMenuDao.java | 13 + .../java/io/as/modules/sys/dao/SysOrgDao.java | 34 + .../io/as/modules/sys/dao/SysRoleDao.java | 12 + .../io/as/modules/sys/dao/SysRoleMenuDao.java | 13 + .../io/as/modules/sys/dao/SysUserDao.java | 14 + .../io/as/modules/sys/dao/SysUserRoleDao.java | 13 + .../as/modules/sys/dao/SysUserTokenDao.java | 19 + .../as/modules/sys/entity/AuditLogEntity.java | 49 + .../io/as/modules/sys/entity/CardEntity.java | 74 + .../sys/entity/CardTransferEntity.java | 28 + .../entity/ContractActivationCodeEntity.java | 29 + .../as/modules/sys/entity/ContractEntity.java | 27 + .../sys/entity/StoreProfileEntity.java | 61 + .../modules/sys/entity/SysCaptchaEntity.java | 17 + .../modules/sys/entity/SysConfigEntity.java | 25 + .../as/modules/sys/entity/SysMenuEntity.java | 42 + .../as/modules/sys/entity/SysOrgEntity.java | 254 ++ .../as/modules/sys/entity/SysRoleEntity.java | 28 + .../modules/sys/entity/SysRoleMenuEntity.java | 17 + .../as/modules/sys/entity/SysUserEntity.java | 104 + .../modules/sys/entity/SysUserRoleEntity.java | 17 + .../sys/entity/SysUserTokenEntity.java | 23 + .../as/modules/sys/form/ByStoreRequest.java | 15 + .../form/ContractActivationCodeRequest.java | 20 + .../io/as/modules/sys/form/PasswordForm.java | 9 + .../io/as/modules/sys/form/SysLoginForm.java | 11 + .../sys/form/UpdateMaxDeviceCountRequest.java | 20 + .../java/io/as/modules/sys/jwt/JWTFilter.java | 76 + .../java/io/as/modules/sys/jwt/JWTRealm.java | 50 + .../java/io/as/modules/sys/jwt/JWTToken.java | 21 + .../io/as/modules/sys/jwt/TokenGenerator.java | 44 + .../as/modules/sys/redis/SysConfigRedis.java | 37 + .../modules/sys/service/AuditLogService.java | 17 + .../sys/service/CardTransferService.java | 26 + .../modules/sys/service/ContractService.java | 21 + .../as/modules/sys/service/ShiroService.java | 12 + .../sys/service/StoreProfileService.java | 10 + .../sys/service/SysCaptchaService.java | 11 + .../modules/sys/service/SysConfigService.java | 52 + .../sys/service/SysConfigServiceImpl.java | 1 + .../modules/sys/service/SysMenuService.java | 14 + .../as/modules/sys/service/SysOrgService.java | 165 ++ .../sys/service/SysRoleMenuService.java | 12 + .../modules/sys/service/SysRoleService.java | 16 + .../sys/service/SysUserRoleService.java | 12 + .../modules/sys/service/SysUserService.java | 19 + .../sys/service/SysUserTokenService.java | 11 + .../sys/service/impl/AuditLogServiceImpl.java | 20 + .../service/impl/CardTransferServiceImpl.java | 17 + .../sys/service/impl/ContractServiceImpl.java | 20 + .../sys/service/impl/ShiroServiceImpl.java | 61 + .../service/impl/StoreProfileServiceImpl.java | 14 + .../service/impl/SysCaptchaServiceImpl.java | 49 + .../service/impl/SysConfigServiceImpl.java | 96 + .../sys/service/impl/SysMenuServiceImpl.java | 91 + .../sys/service/impl/SysOrgServiceImpl.java | 2420 +++++++++++++++++ .../service/impl/SysRoleMenuServiceImpl.java | 39 + .../sys/service/impl/SysRoleServiceImpl.java | 88 + .../service/impl/SysUserRoleServiceImpl.java | 38 + .../sys/service/impl/SysUserServiceImpl.java | 157 ++ .../service/impl/SysUserTokenServiceImpl.java | 163 ++ .../modules/sys/utils/DeviceImportUtils.java | 32 + .../sys/vo/ApiJstCallLogPageRowVo.java | 40 + .../as/modules/sys/vo/CardStatsByTypeVo.java | 32 + .../modules/sys/vo/CardTypeDetailStatsVo.java | 50 + .../modules/sys/vo/OrgCardStatsByTypeVo.java | 43 + .../io/as/modules/sys/vo/OrgCardStatsVo.java | 24 + .../sys/vo/OrgCardTypeAllocatableVo.java | 26 + .../modules/sys/vo/StoreCardTypeStatsVo.java | 32 + .../io/as/modules/sys/vo/SysUserListVo.java | 22 + src/main/resources/application-dev.yml | 43 + src/main/resources/application.yml | 101 + src/main/resources/logback-spring.xml | 47 + .../mapper/daycard/DayCardPoolDao.xml | 21 + .../mapper/device/CardDeviceBindingDao.xml | 5 + .../resources/mapper/device/DeviceDao.xml | 351 +++ .../resources/mapper/jst/ApiJstCallLogDao.xml | 53 + .../resources/mapper/sys/StoreProfileDao.xml | 5 + .../resources/mapper/sys/SysConfigDao.xml | 19 + src/main/resources/mapper/sys/SysMenuDao.xml | 10 + src/main/resources/mapper/sys/SysOrgDao.xml | 56 + src/main/resources/mapper/sys/SysRoleDao.xml | 7 + .../resources/mapper/sys/SysRoleMenuDao.xml | 13 + src/main/resources/mapper/sys/SysUserDao.xml | 18 + .../resources/mapper/sys/SysUserRoleDao.xml | 13 + .../resources/mapper/sys/SysUserTokenDao.xml | 24 + start.sh | 52 + 橘视厅酒店版openapi文档.md | 481 ++++ 第三方App测试需求.md | 258 ++ 第三方合作伙伴接口流程.md | 194 ++ 349 files changed, 28837 insertions(+) create mode 100644 .gitignore create mode 100755 .trae/rules/git-commit-message.md create mode 100644 API_DOC.md create mode 100644 Dockerfile create mode 100644 HANDOVER.md create mode 100644 db/add_device_source_field.sql create mode 100644 db/audit_log_rebuild.sql create mode 100644 db/device-info-fields.sql create mode 100644 db/device-tables.sql create mode 100644 db/full-schema.sql create mode 100644 db/init-sys-data.sql create mode 100644 db/mysql.sql create mode 100644 db/openapi_media.sql create mode 100644 db/openapi_partner_card_pool.sql create mode 100644 db/price-config-3layer.sql create mode 100644 db/restore_test_card_and_jst_config.sql create mode 100644 db/schedule-timeout-task.sql create mode 100644 docker-compose.yml create mode 100644 pom.xml create mode 100644 src/main/java/io/as/AsApplication.java create mode 100755 src/main/java/io/as/common/annotation/AuditAction.java create mode 100755 src/main/java/io/as/common/annotation/SysLog.java create mode 100755 src/main/java/io/as/common/aspect/ApiLogAspect.java create mode 100755 src/main/java/io/as/common/aspect/AuditLogAspect.java create mode 100755 src/main/java/io/as/common/aspect/RedisAspect.java create mode 100755 src/main/java/io/as/common/aspect/SysLogAspect.java create mode 100755 src/main/java/io/as/common/exception/ASException.java create mode 100755 src/main/java/io/as/common/exception/ASExceptionHandler.java create mode 100755 src/main/java/io/as/common/utils/BeanFindUtils.java create mode 100755 src/main/java/io/as/common/utils/ConfigConstant.java create mode 100755 src/main/java/io/as/common/utils/Constant.java create mode 100755 src/main/java/io/as/common/utils/CustomHeaderCaptureUtils.java create mode 100755 src/main/java/io/as/common/utils/DateUtils.java create mode 100755 src/main/java/io/as/common/utils/HttpContextUtils.java create mode 100755 src/main/java/io/as/common/utils/IPUtils.java create mode 100755 src/main/java/io/as/common/utils/MacAddressUtils.java create mode 100755 src/main/java/io/as/common/utils/MapUtils.java create mode 100755 src/main/java/io/as/common/utils/PageUtils.java create mode 100755 src/main/java/io/as/common/utils/Query.java create mode 100755 src/main/java/io/as/common/utils/R.java create mode 100755 src/main/java/io/as/common/utils/RedisKeys.java create mode 100755 src/main/java/io/as/common/utils/RedisUtils.java create mode 100755 src/main/java/io/as/common/utils/RsaUtil.java create mode 100755 src/main/java/io/as/common/utils/ShiroUtils.java create mode 100755 src/main/java/io/as/common/utils/SpringContextUtils.java create mode 100755 src/main/java/io/as/common/validator/Assert.java create mode 100755 src/main/java/io/as/common/validator/ValidatorUtils.java create mode 100755 src/main/java/io/as/common/validator/group/AddGroup.java create mode 100755 src/main/java/io/as/common/validator/group/Group.java create mode 100755 src/main/java/io/as/common/validator/group/QcloudGroup.java create mode 100755 src/main/java/io/as/common/validator/group/UpdateGroup.java create mode 100755 src/main/java/io/as/common/xss/HTMLFilter.java create mode 100755 src/main/java/io/as/common/xss/SQLFilter.java create mode 100755 src/main/java/io/as/common/xss/XssFilter.java create mode 100755 src/main/java/io/as/common/xss/XssHttpServletRequestWrapper.java create mode 100755 src/main/java/io/as/config/CorsConfig.java create mode 100755 src/main/java/io/as/config/FilterConfig.java create mode 100644 src/main/java/io/as/config/KaptchaConfig.java create mode 100755 src/main/java/io/as/config/MybatisPlusConfig.java create mode 100755 src/main/java/io/as/config/RedisConfig.java create mode 100755 src/main/java/io/as/config/ShiroConfig.java create mode 100755 src/main/java/io/as/config/SimpleCorsFilter.java create mode 100755 src/main/java/io/as/config/SwaggerConfig.java create mode 100755 src/main/java/io/as/datasource/annotation/DataSource.java create mode 100755 src/main/java/io/as/datasource/aspect/DataSourceAspect.java create mode 100755 src/main/java/io/as/datasource/config/DynamicContextHolder.java create mode 100755 src/main/java/io/as/datasource/config/DynamicDataSource.java create mode 100755 src/main/java/io/as/datasource/config/DynamicDataSourceConfig.java create mode 100755 src/main/java/io/as/datasource/config/DynamicDataSourceFactory.java create mode 100755 src/main/java/io/as/datasource/properties/DataSourceProperties.java create mode 100755 src/main/java/io/as/datasource/properties/DynamicDataSourceProperties.java create mode 100644 src/main/java/io/as/modules/daycard/constants/DayCardConstants.java create mode 100644 src/main/java/io/as/modules/daycard/controller/DayCardActivationCodeController.java create mode 100644 src/main/java/io/as/modules/daycard/controller/DayCardManageController.java create mode 100644 src/main/java/io/as/modules/daycard/controller/DayCardOrderController.java create mode 100644 src/main/java/io/as/modules/daycard/controller/DayCardPriceConfigController.java create mode 100644 src/main/java/io/as/modules/daycard/controller/DayCardRefundController.java create mode 100644 src/main/java/io/as/modules/daycard/controller/DayCardSettleController.java create mode 100644 src/main/java/io/as/modules/daycard/controller/OrgSettlementAccountController.java create mode 100644 src/main/java/io/as/modules/daycard/controller/ProfitSharingQueryController.java create mode 100644 src/main/java/io/as/modules/daycard/controller/ProfitSharingRuleController.java create mode 100644 src/main/java/io/as/modules/daycard/dao/DayCardActivationCodeDao.java create mode 100644 src/main/java/io/as/modules/daycard/dao/DayCardImportBatchDao.java create mode 100644 src/main/java/io/as/modules/daycard/dao/DayCardOrderDao.java create mode 100644 src/main/java/io/as/modules/daycard/dao/DayCardPoolDao.java create mode 100644 src/main/java/io/as/modules/daycard/dao/DayCardPriceConfigDao.java create mode 100644 src/main/java/io/as/modules/daycard/dao/DayCardProfitReceiverDao.java create mode 100644 src/main/java/io/as/modules/daycard/dao/DayCardProfitRuleDao.java create mode 100644 src/main/java/io/as/modules/daycard/dao/DayCardRefundDao.java create mode 100644 src/main/java/io/as/modules/daycard/dao/DayCardSettleOrderDao.java create mode 100644 src/main/java/io/as/modules/daycard/dao/DayCardSettleRecordDao.java create mode 100644 src/main/java/io/as/modules/daycard/dao/DayCardSettleRuleDao.java create mode 100644 src/main/java/io/as/modules/daycard/dao/OrgSettlementAccountDao.java create mode 100644 src/main/java/io/as/modules/daycard/dao/ProfitSharingDetailDao.java create mode 100644 src/main/java/io/as/modules/daycard/dao/ProfitSharingOrderDao.java create mode 100644 src/main/java/io/as/modules/daycard/dao/ProfitSharingRuleDao.java create mode 100644 src/main/java/io/as/modules/daycard/dao/ProfitSharingRuleItemDao.java create mode 100644 src/main/java/io/as/modules/daycard/entity/DayCardActivationCodeEntity.java create mode 100644 src/main/java/io/as/modules/daycard/entity/DayCardImportBatchEntity.java create mode 100644 src/main/java/io/as/modules/daycard/entity/DayCardOrderEntity.java create mode 100644 src/main/java/io/as/modules/daycard/entity/DayCardPoolEntity.java create mode 100644 src/main/java/io/as/modules/daycard/entity/DayCardPriceConfigEntity.java create mode 100644 src/main/java/io/as/modules/daycard/entity/DayCardProfitReceiverEntity.java create mode 100644 src/main/java/io/as/modules/daycard/entity/DayCardProfitRuleEntity.java create mode 100644 src/main/java/io/as/modules/daycard/entity/DayCardRefundEntity.java create mode 100644 src/main/java/io/as/modules/daycard/entity/DayCardSettleOrderEntity.java create mode 100644 src/main/java/io/as/modules/daycard/entity/DayCardSettleRecordEntity.java create mode 100644 src/main/java/io/as/modules/daycard/entity/DayCardSettleRuleEntity.java create mode 100644 src/main/java/io/as/modules/daycard/entity/OrgSettlementAccountEntity.java create mode 100644 src/main/java/io/as/modules/daycard/entity/ProfitSharingDetailEntity.java create mode 100644 src/main/java/io/as/modules/daycard/entity/ProfitSharingOrderEntity.java create mode 100644 src/main/java/io/as/modules/daycard/entity/ProfitSharingRuleEntity.java create mode 100644 src/main/java/io/as/modules/daycard/entity/ProfitSharingRuleItemEntity.java create mode 100644 src/main/java/io/as/modules/daycard/form/DayCardOrderCreateRequest.java create mode 100644 src/main/java/io/as/modules/daycard/service/DayCardActivationCodeService.java create mode 100644 src/main/java/io/as/modules/daycard/service/DayCardOrderService.java create mode 100644 src/main/java/io/as/modules/daycard/service/DayCardPoolService.java create mode 100644 src/main/java/io/as/modules/daycard/service/DayCardPriceConfigService.java create mode 100644 src/main/java/io/as/modules/daycard/service/DayCardProfitReceiverService.java create mode 100644 src/main/java/io/as/modules/daycard/service/DayCardProfitRuleService.java create mode 100644 src/main/java/io/as/modules/daycard/service/DayCardRefundService.java create mode 100644 src/main/java/io/as/modules/daycard/service/DayCardSettleOrderService.java create mode 100644 src/main/java/io/as/modules/daycard/service/DayCardSettleRecordService.java create mode 100644 src/main/java/io/as/modules/daycard/service/DayCardSettleRuleService.java create mode 100644 src/main/java/io/as/modules/daycard/service/OrgSettlementAccountService.java create mode 100644 src/main/java/io/as/modules/daycard/service/ProfitCalcService.java create mode 100644 src/main/java/io/as/modules/daycard/service/ProfitSharingExecuteService.java create mode 100644 src/main/java/io/as/modules/daycard/service/ProfitSharingRuleService.java create mode 100644 src/main/java/io/as/modules/daycard/service/SettleExecuteService.java create mode 100644 src/main/java/io/as/modules/daycard/service/impl/DayCardActivationCodeServiceImpl.java create mode 100644 src/main/java/io/as/modules/daycard/service/impl/DayCardOrderServiceImpl.java create mode 100644 src/main/java/io/as/modules/daycard/service/impl/DayCardPoolServiceImpl.java create mode 100644 src/main/java/io/as/modules/daycard/service/impl/DayCardPriceConfigServiceImpl.java create mode 100644 src/main/java/io/as/modules/daycard/service/impl/DayCardProfitReceiverServiceImpl.java create mode 100644 src/main/java/io/as/modules/daycard/service/impl/DayCardProfitRuleServiceImpl.java create mode 100644 src/main/java/io/as/modules/daycard/service/impl/DayCardRefundServiceImpl.java create mode 100644 src/main/java/io/as/modules/daycard/service/impl/DayCardSettleOrderServiceImpl.java create mode 100644 src/main/java/io/as/modules/daycard/service/impl/DayCardSettleRecordServiceImpl.java create mode 100644 src/main/java/io/as/modules/daycard/service/impl/DayCardSettleRuleServiceImpl.java create mode 100644 src/main/java/io/as/modules/daycard/service/impl/OrgSettlementAccountServiceImpl.java create mode 100644 src/main/java/io/as/modules/daycard/service/impl/ProfitCalcServiceImpl.java create mode 100644 src/main/java/io/as/modules/daycard/service/impl/ProfitSharingExecuteServiceImpl.java create mode 100644 src/main/java/io/as/modules/daycard/service/impl/ProfitSharingRuleServiceImpl.java create mode 100644 src/main/java/io/as/modules/daycard/service/impl/SettleExecuteServiceImpl.java create mode 100644 src/main/java/io/as/modules/daycard/task/DayCardActivateRetryTask.java create mode 100644 src/main/java/io/as/modules/daycard/task/DayCardOrderTimeoutTask.java create mode 100644 src/main/java/io/as/modules/daycard/task/ProfitSharingQueryTask.java create mode 100644 src/main/java/io/as/modules/daycard/vo/DayCardOrderVo.java create mode 100644 src/main/java/io/as/modules/daycard/vo/DayCardPoolStatsVo.java create mode 100755 src/main/java/io/as/modules/device/constants/DeviceConstants.java create mode 100644 src/main/java/io/as/modules/device/controller/DeviceController.java create mode 100755 src/main/java/io/as/modules/device/dao/CardDeviceBindingDao.java create mode 100755 src/main/java/io/as/modules/device/dao/DeviceDao.java create mode 100644 src/main/java/io/as/modules/device/dto/DeviceOrgContext.java create mode 100755 src/main/java/io/as/modules/device/entity/CardDeviceBindingEntity.java create mode 100755 src/main/java/io/as/modules/device/entity/DeviceEntity.java create mode 100644 src/main/java/io/as/modules/device/service/DeviceResolveService.java create mode 100644 src/main/java/io/as/modules/device/service/DeviceService.java create mode 100644 src/main/java/io/as/modules/device/service/impl/DeviceResolveServiceImpl.java create mode 100644 src/main/java/io/as/modules/device/service/impl/DeviceServiceImpl.java create mode 100755 src/main/java/io/as/modules/job/config/ScheduleConfig.java create mode 100755 src/main/java/io/as/modules/job/dao/ScheduleJobDao.java create mode 100755 src/main/java/io/as/modules/job/dao/ScheduleJobLogDao.java create mode 100755 src/main/java/io/as/modules/job/dto/JobIdsRequest.java create mode 100755 src/main/java/io/as/modules/job/dto/ScheduleJobIdsDeserializer.java create mode 100755 src/main/java/io/as/modules/job/entity/ScheduleJobEntity.java create mode 100755 src/main/java/io/as/modules/job/entity/ScheduleJobLogEntity.java create mode 100755 src/main/java/io/as/modules/job/service/ScheduleJobLogService.java create mode 100755 src/main/java/io/as/modules/job/service/ScheduleJobService.java create mode 100755 src/main/java/io/as/modules/job/service/impl/ScheduleJobLogServiceImpl.java create mode 100755 src/main/java/io/as/modules/job/service/impl/ScheduleJobServiceImpl.java create mode 100755 src/main/java/io/as/modules/job/task/ITask.java create mode 100755 src/main/java/io/as/modules/job/utils/ScheduleJob.java create mode 100755 src/main/java/io/as/modules/job/utils/ScheduleUtils.java create mode 100755 src/main/java/io/as/modules/jst/config/AsyncConfig.java create mode 100755 src/main/java/io/as/modules/jst/config/JstPlatformConfig.java create mode 100755 src/main/java/io/as/modules/jst/dao/ApiJstCallLogDao.java create mode 100755 src/main/java/io/as/modules/jst/entity/ApiJstCallLogEntity.java create mode 100755 src/main/java/io/as/modules/jst/service/ApiJstCallLogService.java create mode 100755 src/main/java/io/as/modules/jst/service/JstActivateCardResult.java create mode 100755 src/main/java/io/as/modules/jst/service/JushitingApiService.java create mode 100755 src/main/java/io/as/modules/jst/service/impl/ApiJstCallLogServiceImpl.java create mode 100755 src/main/java/io/as/modules/jst/service/impl/JushitingApiServiceImpl.java create mode 100644 src/main/java/io/as/modules/media/client/JetsenMediaClient.java create mode 100644 src/main/java/io/as/modules/media/config/JetsenMediaConfig.java create mode 100644 src/main/java/io/as/modules/media/controller/JetsenMediaController.java create mode 100644 src/main/java/io/as/modules/media/controller/MediaController.java create mode 100644 src/main/java/io/as/modules/media/service/MediaService.java create mode 100644 src/main/java/io/as/modules/media/service/impl/MediaServiceImpl.java create mode 100755 src/main/java/io/as/modules/openapi/config/ApiConfig.java create mode 100644 src/main/java/io/as/modules/openapi/config/OpenApiConfig.java create mode 100644 src/main/java/io/as/modules/openapi/constants/OpenApiConstants.java create mode 100644 src/main/java/io/as/modules/openapi/controller/ApiJstController.java create mode 100644 src/main/java/io/as/modules/openapi/controller/ApiJstUiController.java create mode 100644 src/main/java/io/as/modules/openapi/controller/ContentPackageController.java create mode 100644 src/main/java/io/as/modules/openapi/controller/OpenApiCallLogController.java create mode 100644 src/main/java/io/as/modules/openapi/controller/OpenApiMediaController.java create mode 100644 src/main/java/io/as/modules/openapi/controller/OpenApiPartnerCardController.java create mode 100644 src/main/java/io/as/modules/openapi/controller/OpenApiPartnerController.java create mode 100644 src/main/java/io/as/modules/openapi/controller/PartnerCardPurchaseController.java create mode 100644 src/main/java/io/as/modules/openapi/dao/ContentPackageDao.java create mode 100644 src/main/java/io/as/modules/openapi/dao/OpenApiCallLogDao.java create mode 100644 src/main/java/io/as/modules/openapi/dao/OpenApiPartnerDao.java create mode 100644 src/main/java/io/as/modules/openapi/dao/PartnerCardPoolDao.java create mode 100644 src/main/java/io/as/modules/openapi/entity/ContentPackageEntity.java create mode 100644 src/main/java/io/as/modules/openapi/entity/OpenApiCallLogEntity.java create mode 100644 src/main/java/io/as/modules/openapi/entity/OpenApiPartnerEntity.java create mode 100644 src/main/java/io/as/modules/openapi/entity/PartnerCardPoolEntity.java create mode 100755 src/main/java/io/as/modules/openapi/filter/ApiDeviceSignFilter.java create mode 100755 src/main/java/io/as/modules/openapi/filter/ApiJstCallLogFilter.java create mode 100755 src/main/java/io/as/modules/openapi/filter/ByteArrayReplayingHttpServletRequest.java create mode 100644 src/main/java/io/as/modules/openapi/filter/PartnerSignFilter.java create mode 100644 src/main/java/io/as/modules/openapi/form/DeviceInitRequest.java create mode 100644 src/main/java/io/as/modules/openapi/service/ContentPackageService.java create mode 100644 src/main/java/io/as/modules/openapi/service/OpenApiCallLogService.java create mode 100644 src/main/java/io/as/modules/openapi/service/OpenApiPartnerService.java create mode 100644 src/main/java/io/as/modules/openapi/service/PartnerCardPoolService.java create mode 100644 src/main/java/io/as/modules/openapi/service/impl/ContentPackageServiceImpl.java create mode 100644 src/main/java/io/as/modules/openapi/service/impl/OpenApiCallLogServiceImpl.java create mode 100644 src/main/java/io/as/modules/openapi/service/impl/OpenApiPartnerServiceImpl.java create mode 100644 src/main/java/io/as/modules/openapi/service/impl/PartnerCardPoolServiceImpl.java create mode 100755 src/main/java/io/as/modules/openapi/sign/ApiDeviceSignService.java create mode 100755 src/main/java/io/as/modules/openapi/sign/DeviceSignCache.java create mode 100755 src/main/java/io/as/modules/openapi/sign/DeviceSignResult.java create mode 100755 src/main/java/io/as/modules/openapi/sign/impl/ApiDeviceSignServiceImpl.java create mode 100644 src/main/java/io/as/modules/payment/config/WechatPayConfig.java create mode 100644 src/main/java/io/as/modules/payment/controller/PaymentController.java create mode 100644 src/main/java/io/as/modules/payment/controller/ProfitSharingCallbackController.java create mode 100644 src/main/java/io/as/modules/payment/controller/SettleCallbackController.java create mode 100644 src/main/java/io/as/modules/payment/controller/WechatPayConfigController.java create mode 100644 src/main/java/io/as/modules/payment/dao/WechatPayConfigDao.java create mode 100644 src/main/java/io/as/modules/payment/entity/WechatPayConfigEntity.java create mode 100644 src/main/java/io/as/modules/payment/form/PaymentCreateRequest.java create mode 100644 src/main/java/io/as/modules/payment/service/WechatPayConfigService.java create mode 100644 src/main/java/io/as/modules/payment/service/WechatPayResult.java create mode 100644 src/main/java/io/as/modules/payment/service/WechatPayService.java create mode 100644 src/main/java/io/as/modules/payment/service/impl/WechatPayConfigServiceImpl.java create mode 100644 src/main/java/io/as/modules/payment/service/impl/WechatPayServiceImpl.java create mode 100644 src/main/java/io/as/modules/stats/controller/StatsController.java create mode 100644 src/main/java/io/as/modules/stats/service/StatsService.java create mode 100644 src/main/java/io/as/modules/stats/service/impl/StatsServiceImpl.java create mode 100755 src/main/java/io/as/modules/sys/constants/OrgCodeConstants.java create mode 100644 src/main/java/io/as/modules/sys/constants/OrgTypeRoleConstants.java create mode 100644 src/main/java/io/as/modules/sys/controller/AbstractController.java create mode 100644 src/main/java/io/as/modules/sys/controller/ContractController.java create mode 100644 src/main/java/io/as/modules/sys/controller/StoreController.java create mode 100644 src/main/java/io/as/modules/sys/controller/SysLoginController.java create mode 100644 src/main/java/io/as/modules/sys/controller/SysMenuController.java create mode 100644 src/main/java/io/as/modules/sys/controller/SysOrgController.java create mode 100644 src/main/java/io/as/modules/sys/controller/SysRoleController.java create mode 100644 src/main/java/io/as/modules/sys/controller/SysUserController.java create mode 100755 src/main/java/io/as/modules/sys/dao/AuditLogDao.java create mode 100644 src/main/java/io/as/modules/sys/dao/CardDao.java create mode 100644 src/main/java/io/as/modules/sys/dao/CardTransferDao.java create mode 100644 src/main/java/io/as/modules/sys/dao/ContractActivationCodeDao.java create mode 100644 src/main/java/io/as/modules/sys/dao/ContractDao.java create mode 100755 src/main/java/io/as/modules/sys/dao/StoreProfileDao.java create mode 100644 src/main/java/io/as/modules/sys/dao/SysCaptchaDao.java create mode 100755 src/main/java/io/as/modules/sys/dao/SysConfigDao.java create mode 100644 src/main/java/io/as/modules/sys/dao/SysMenuDao.java create mode 100644 src/main/java/io/as/modules/sys/dao/SysOrgDao.java create mode 100644 src/main/java/io/as/modules/sys/dao/SysRoleDao.java create mode 100644 src/main/java/io/as/modules/sys/dao/SysRoleMenuDao.java create mode 100644 src/main/java/io/as/modules/sys/dao/SysUserDao.java create mode 100644 src/main/java/io/as/modules/sys/dao/SysUserRoleDao.java create mode 100644 src/main/java/io/as/modules/sys/dao/SysUserTokenDao.java create mode 100755 src/main/java/io/as/modules/sys/entity/AuditLogEntity.java create mode 100755 src/main/java/io/as/modules/sys/entity/CardEntity.java create mode 100755 src/main/java/io/as/modules/sys/entity/CardTransferEntity.java create mode 100755 src/main/java/io/as/modules/sys/entity/ContractActivationCodeEntity.java create mode 100755 src/main/java/io/as/modules/sys/entity/ContractEntity.java create mode 100755 src/main/java/io/as/modules/sys/entity/StoreProfileEntity.java create mode 100644 src/main/java/io/as/modules/sys/entity/SysCaptchaEntity.java create mode 100755 src/main/java/io/as/modules/sys/entity/SysConfigEntity.java create mode 100644 src/main/java/io/as/modules/sys/entity/SysMenuEntity.java create mode 100755 src/main/java/io/as/modules/sys/entity/SysOrgEntity.java create mode 100644 src/main/java/io/as/modules/sys/entity/SysRoleEntity.java create mode 100644 src/main/java/io/as/modules/sys/entity/SysRoleMenuEntity.java create mode 100755 src/main/java/io/as/modules/sys/entity/SysUserEntity.java create mode 100644 src/main/java/io/as/modules/sys/entity/SysUserRoleEntity.java create mode 100644 src/main/java/io/as/modules/sys/entity/SysUserTokenEntity.java create mode 100644 src/main/java/io/as/modules/sys/form/ByStoreRequest.java create mode 100644 src/main/java/io/as/modules/sys/form/ContractActivationCodeRequest.java create mode 100644 src/main/java/io/as/modules/sys/form/PasswordForm.java create mode 100644 src/main/java/io/as/modules/sys/form/SysLoginForm.java create mode 100644 src/main/java/io/as/modules/sys/form/UpdateMaxDeviceCountRequest.java create mode 100644 src/main/java/io/as/modules/sys/jwt/JWTFilter.java create mode 100644 src/main/java/io/as/modules/sys/jwt/JWTRealm.java create mode 100644 src/main/java/io/as/modules/sys/jwt/JWTToken.java create mode 100755 src/main/java/io/as/modules/sys/jwt/TokenGenerator.java create mode 100755 src/main/java/io/as/modules/sys/redis/SysConfigRedis.java create mode 100755 src/main/java/io/as/modules/sys/service/AuditLogService.java create mode 100755 src/main/java/io/as/modules/sys/service/CardTransferService.java create mode 100755 src/main/java/io/as/modules/sys/service/ContractService.java create mode 100644 src/main/java/io/as/modules/sys/service/ShiroService.java create mode 100755 src/main/java/io/as/modules/sys/service/StoreProfileService.java create mode 100644 src/main/java/io/as/modules/sys/service/SysCaptchaService.java create mode 100755 src/main/java/io/as/modules/sys/service/SysConfigService.java create mode 100755 src/main/java/io/as/modules/sys/service/SysConfigServiceImpl.java create mode 100644 src/main/java/io/as/modules/sys/service/SysMenuService.java create mode 100644 src/main/java/io/as/modules/sys/service/SysOrgService.java create mode 100644 src/main/java/io/as/modules/sys/service/SysRoleMenuService.java create mode 100644 src/main/java/io/as/modules/sys/service/SysRoleService.java create mode 100644 src/main/java/io/as/modules/sys/service/SysUserRoleService.java create mode 100644 src/main/java/io/as/modules/sys/service/SysUserService.java create mode 100644 src/main/java/io/as/modules/sys/service/SysUserTokenService.java create mode 100755 src/main/java/io/as/modules/sys/service/impl/AuditLogServiceImpl.java create mode 100644 src/main/java/io/as/modules/sys/service/impl/CardTransferServiceImpl.java create mode 100644 src/main/java/io/as/modules/sys/service/impl/ContractServiceImpl.java create mode 100644 src/main/java/io/as/modules/sys/service/impl/ShiroServiceImpl.java create mode 100755 src/main/java/io/as/modules/sys/service/impl/StoreProfileServiceImpl.java create mode 100644 src/main/java/io/as/modules/sys/service/impl/SysCaptchaServiceImpl.java create mode 100755 src/main/java/io/as/modules/sys/service/impl/SysConfigServiceImpl.java create mode 100644 src/main/java/io/as/modules/sys/service/impl/SysMenuServiceImpl.java create mode 100644 src/main/java/io/as/modules/sys/service/impl/SysOrgServiceImpl.java create mode 100644 src/main/java/io/as/modules/sys/service/impl/SysRoleMenuServiceImpl.java create mode 100644 src/main/java/io/as/modules/sys/service/impl/SysRoleServiceImpl.java create mode 100644 src/main/java/io/as/modules/sys/service/impl/SysUserRoleServiceImpl.java create mode 100644 src/main/java/io/as/modules/sys/service/impl/SysUserServiceImpl.java create mode 100644 src/main/java/io/as/modules/sys/service/impl/SysUserTokenServiceImpl.java create mode 100755 src/main/java/io/as/modules/sys/utils/DeviceImportUtils.java create mode 100755 src/main/java/io/as/modules/sys/vo/ApiJstCallLogPageRowVo.java create mode 100755 src/main/java/io/as/modules/sys/vo/CardStatsByTypeVo.java create mode 100755 src/main/java/io/as/modules/sys/vo/CardTypeDetailStatsVo.java create mode 100755 src/main/java/io/as/modules/sys/vo/OrgCardStatsByTypeVo.java create mode 100755 src/main/java/io/as/modules/sys/vo/OrgCardStatsVo.java create mode 100755 src/main/java/io/as/modules/sys/vo/OrgCardTypeAllocatableVo.java create mode 100755 src/main/java/io/as/modules/sys/vo/StoreCardTypeStatsVo.java create mode 100644 src/main/java/io/as/modules/sys/vo/SysUserListVo.java create mode 100644 src/main/resources/application-dev.yml create mode 100644 src/main/resources/application.yml create mode 100755 src/main/resources/logback-spring.xml create mode 100644 src/main/resources/mapper/daycard/DayCardPoolDao.xml create mode 100755 src/main/resources/mapper/device/CardDeviceBindingDao.xml create mode 100755 src/main/resources/mapper/device/DeviceDao.xml create mode 100755 src/main/resources/mapper/jst/ApiJstCallLogDao.xml create mode 100755 src/main/resources/mapper/sys/StoreProfileDao.xml create mode 100644 src/main/resources/mapper/sys/SysConfigDao.xml create mode 100644 src/main/resources/mapper/sys/SysMenuDao.xml create mode 100755 src/main/resources/mapper/sys/SysOrgDao.xml create mode 100644 src/main/resources/mapper/sys/SysRoleDao.xml create mode 100644 src/main/resources/mapper/sys/SysRoleMenuDao.xml create mode 100644 src/main/resources/mapper/sys/SysUserDao.xml create mode 100644 src/main/resources/mapper/sys/SysUserRoleDao.xml create mode 100644 src/main/resources/mapper/sys/SysUserTokenDao.xml create mode 100755 start.sh create mode 100644 橘视厅酒店版openapi文档.md create mode 100644 第三方App测试需求.md create mode 100644 第三方合作伙伴接口流程.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee0e886 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Build output +target/ +*.class + +# IDE +.idea/ +*.iml +.vscode/ +*.swp + +# Logs +logs/ +*.log + +# Redis dump +dump.rdb + +# Python test scripts (not part of project) +*.py + +# TRAE generated +.trae-html-share-packages/ + +# OS files +.DS_Store +Thumbs.db + +# Sensitive config (keep templates) +# application-prod.yml contains production credentials +src/main/resources/application-prod.yml diff --git a/.trae/rules/git-commit-message.md b/.trae/rules/git-commit-message.md new file mode 100755 index 0000000..21302b2 --- /dev/null +++ b/.trae/rules/git-commit-message.md @@ -0,0 +1,7 @@ +--- +alwaysApply: true +scene: git_message +--- + +在此处编写规则,自定义 AI 生成提交信息的风格。 +使用中文编写提交信息。 diff --git a/API_DOC.md b/API_DOC.md new file mode 100644 index 0000000..56baf69 --- /dev/null +++ b/API_DOC.md @@ -0,0 +1,388 @@ +# 天卡开放平台 · 接口对接文档 + +> 项目:as-daycard-openapi | 端口:8084 | 更新:2026-07-22 + +--- + +## 一、统一规范 + +### 返回格式(所有接口统一) + +```json +{"code": 0, "msg": "success", "data": {...}} +``` +- `code=0` 成功,非0失败 +- `data` 业务数据,统一放在 data 字段内 +- 错误时:`{"code": 错误码, "msg": "错误信息", "data": {}}` + +### 接口分类 + +| 分类 | 路径前缀 | 鉴权 | 请求头 | 调用方 | +|------|---------|------|--------|--------| +| 管理后台 | `/manage/**`、`/sys/login` | JWT | `token` | 浏览器前端 | +| 第三方开放 | `/openapi/partner/**` | appKey签名 | `appKey`/`timestamp`/`nonce`/`sign` | 第三方EPG | +| APK设备 | `/api/jst/**` | 设备sign | `sign` | 我们APK | +| 支付回调 | `/openapi/payment/**`、`/openapi/settle/**` | 无 | - | 微信服务器 | + +--- + +## 二、管理后台接口(JWT鉴权) + +**鉴权**:请求头 `token: <登录返回的token>` +**登录**:`POST /sys/login`(免鉴权)返回 token +**返回**:`{code:0, msg:"success", data:{...}}` + +### 2.1 登录鉴权 + +| 路径 | 方法 | 说明 | +|------|------|------| +| `POST /sys/login` | POST | 登录(username/password/uuid/captcha),返回token+refreshToken | +| `POST /sys/refresh` | POST | 刷新token(refreshToken) | +| `POST /sys/logout` | POST | 退出登录 | +| `GET /captcha.jpg` | GET | 验证码图片(参数uuid) | + +### 2.2 用户管理 `/manage/user` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/user/list` | GET | 用户列表(分页) | +| `/manage/user/info` | GET | 当前登录用户信息 | +| `/manage/user/info/{userId}` | GET | 用户详情 | +| `/manage/user/save` | POST | 保存用户 | +| `/manage/user/update` | POST | 修改用户 | +| `/manage/user/delete` | POST | 删除用户 | +| `/manage/user/password` | POST | 修改密码 | + +### 2.3 角色管理 `/manage/role` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/role/list` | GET | 角色列表 | +| `/manage/role/select` | GET | 角色下拉 | +| `/manage/role/info/{roleId}` | GET | 角色详情(含menuIdList) | +| `/manage/role/save` | POST | 保存角色 | +| `/manage/role/update` | POST | 修改角色 | +| `/manage/role/delete` | POST | 删除角色 | + +### 2.4 菜单管理 `/manage/menu` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/menu/nav` | GET | 当前用户菜单+权限 | +| `/manage/menu/list` | GET | 菜单列表 | +| `/manage/menu/select` | GET | 菜单下拉 | +| `/manage/menu/info/{menuId}` | GET | 菜单详情 | +| `/manage/menu/save` | POST | 保存菜单 | +| `/manage/menu/update` | POST | 修改菜单 | +| `/manage/menu/delete/{menuId}` | POST | 删除菜单 | + +### 2.5 组织管理 `/manage/org` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/org/list` | GET | 组织列表(树) | +| `/manage/org/tree` | GET | 组织树(下拉) | +| `/manage/org/all-tree` | GET | 组织树(含门店) | +| `/manage/org/list-data` | GET | 组织列表(扁平) | +| `/manage/org/channels` | GET | 渠道列表 | +| `/manage/org/channel/{channelId}/agent-tree` | GET | 渠道下代理树 | +| `/manage/org/{orgId}/card-stats` | GET | 组织卡统计 | +| `/manage/org/card-type-detail-stats` | GET | 卡类型明细统计 | +| `/manage/org/card-overview` | GET | 组织卡概览 | +| `/manage/org/{orgId}/same-channel-orgs` | GET | 同渠道组织树 | +| `/manage/org/info/{id}` | GET | 组织详情 | +| `/manage/org/save` | POST | 保存组织 | +| `/manage/org/update` | POST | 修改组织 | +| `/manage/org/delete` | POST | 删除组织 | + +### 2.6 门店管理 `/manage/store` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/store/list` | GET | 门店列表 | +| `/manage/store/list-by-agent/{agentOrgId}` | GET | 按代理商列门店 | +| `/manage/store/search` | GET | 门店搜索(分页) | +| `/manage/store/{storeOrgId}/cards` | GET | 门店绑定卡列表 | + +### 2.7 合同权益 `/manage/contract` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/contract/by-store` | POST | 按门店查权益激活码 | + +### 2.8 天卡激活码 `/manage/daycard/activation-code` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/daycard/activation-code/list/{storeOrgId}` | GET | 按门店查激活码 | +| `/manage/daycard/activation-code/generate` | POST | 生成激活码 | +| `/manage/daycard/activation-code/{id}/regenerate` | POST | 重新生成code | +| `/manage/daycard/activation-code/{id}/status` | PUT | 启用/禁用 | +| `/manage/daycard/activation-code/{id}` | DELETE | 删除激活码 | + +### 2.9 天卡管理 `/manage/daycard` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/daycard/pool/list` | GET | 天卡池列表 | +| `/manage/daycard/pool/stats` | GET | 库存统计 | +| `/manage/daycard/pool/import` | POST | 导入天卡卡密 | +| `/manage/daycard/order/list` | GET | 天卡订单列表 | +| `/manage/daycard/list` | GET | 天卡订单列表 | +| `/manage/daycard/info/{orderNo}` | GET | 订单详情 | +| `/manage/daycard/benefits/{deviceId}` | GET | 设备天卡权益 | +| `/manage/daycard/device/unbind` | POST | 解绑设备天卡 | +| `/manage/daycard/device/unbind-org` | POST | 设备组织解绑 | + +### 2.10 天卡定价 `/manage/price` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/price/list` | GET | 定价策略列表 | +| `/manage/price/info/{id}` | GET | 定价详情 | +| `/manage/price/effective` | GET | 生效中定价(门店>渠道>平台) | +| `/manage/price/save` | POST | 新增定价 | +| `/manage/price/update` | POST | 修改定价 | +| `/manage/price/delete` | POST | 删除定价 | + +### 2.11 天卡退款 `/manage/refund` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/refund/list` | GET | 退款记录列表 | +| `/manage/refund/info/{refundNo}` | GET | 退款详情 | +| `/manage/refund/apply` | POST | 发起退款(orderNo+reason) | + +### 2.12 分润规则 `/manage/profit-rule` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/profit-rule/list` | GET | 分润规则列表 | +| `/manage/profit-rule/info/{id}` | GET | 规则详情(含items) | +| `/manage/profit-rule/save` | POST | 新增分润规则 | +| `/manage/profit-rule/update` | POST | 修改分润规则 | +| `/manage/profit-rule/delete` | POST | 删除分润规则 | +| `/manage/profit-rule/preview` | POST | 分润试算(超30%告警) | + +### 2.13 分账查询 `/manage/profit-sharing` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/profit-sharing/list` | GET | 分账单列表(含details) | +| `/manage/profit-sharing/info/{id}` | GET | 分账单详情 | +| `/manage/profit-sharing/stats` | GET | 分账统计(按日期/组织) | + +### 2.14 分账接收方 `/manage/profit-receiver` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/profit-receiver/list` | GET | 接收方列表 | +| `/manage/profit-receiver/info/{id}` | GET | 接收方详情 | +| `/manage/profit-receiver/save` | POST | 新增接收方 | +| `/manage/profit-receiver/delete` | POST | 删除接收方 | +| `/manage/profit-receiver/sync/{id}` | POST | 同步到微信 | + +### 2.15 设备管理 `/manage/device` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/device/list` | GET | 设备列表 | +| `/manage/device/info/{id}` | GET | 设备详情 | +| `/manage/device/save` | POST | 新增设备 | +| `/manage/device/update` | POST | 修改设备 | +| `/manage/device/delete` | POST | 删除设备 | +| `/manage/device/bindStore` | POST | 绑定门店 | + +### 2.16 支付配置 `/manage/payconfig` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/payconfig/list` | GET | 支付配置列表 | +| `/manage/payconfig/info/{id}` | GET | 配置详情 | +| `/manage/payconfig/save` | POST | 新增配置 | +| `/manage/payconfig/update` | POST | 修改配置 | +| `/manage/payconfig/delete` | POST | 删除配置 | + +### 2.17 统计看板 `/manage/stats` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/stats/overview` | GET | 总览看板 | +| `/manage/stats/orders` | GET | 订单趋势(startDate/endDate) | +| `/manage/stats/pool` | GET | 库存统计 | +| `/manage/stats/channels` | GET | 渠道统计 | + +### 2.18 橘视厅媒资 `/manage/jetsen-media` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/jetsen-media/nav` | GET | 首页导航 | +| `/manage/jetsen-media/tags` | GET | 栏目标签(parentid) | +| `/manage/jetsen-media/catalog-tree` | GET | 栏目树(一级+二级) | +| `/manage/jetsen-media/list` | GET | 内容列表(catalogid必填) | +| `/manage/jetsen-media/search` | GET | 搜索(keyword) | +| `/manage/jetsen-media/export` | GET | 导出Excel | + +### 2.19 合作伙伴管理 `/manage/partner` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/partner/list` | GET | 合作伙伴列表 | +| `/manage/partner/info/{id}` | GET | 合作伙伴详情 | +| `/manage/partner/save` | POST | 新增(自动生成appKey/appSecret) | +| `/manage/partner/update` | POST | 修改 | +| `/manage/partner/delete` | POST | 删除 | + +### 2.20 内容包管理 `/manage/content-package` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/content-package/list` | GET | 内容包列表 | +| `/manage/content-package/info/{id}` | GET | 内容包详情 | +| `/manage/content-package/info-by-partner/{partnerId}` | GET | 按partner查内容包 | +| `/manage/content-package/save` | POST | 新增内容包 | +| `/manage/content-package/update` | POST | 修改内容包 | +| `/manage/content-package/delete` | POST | 删除内容包 | + +### 2.21 天卡采购 `/manage/partner-card` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/partner-card/purchase` | POST | 采购天卡(partnerId+quantity) | +| `/manage/partner-card/stock` | GET | partner库存查询 | +| `/manage/partner-card/list` | GET | 池子记录分页 | + +--- + +## 三、第三方开放接口(appKey签名鉴权) + +### 鉴权说明 + +**签名算法**:`sign = MD5(appKey + timestamp + nonce + appSecret).toUpperCase()` + +**请求头**: + +| 请求头 | 说明 | +|--------|------| +| appKey | 平台分配的应用Key | +| timestamp | 毫秒时间戳(5分钟有效) | +| nonce | 随机串(5分钟防重放) | +| sign | 签名(大写MD5) | + +**返回**:`{code:0, msg:"", data:{...}}` +**鉴权失败**:HTTP 401 `{code:401, msg:"错误描述"}` + +### 3.1 媒资接口 `/openapi/partner/media` + +**三级授权**:CONTENT(单部内容) > CATALOG(栏目) > FULL(全量) + +| 路径 | 方法 | 参数 | 说明 | +|------|------|------|------| +| `/openapi/partner/media/package` | GET | 无 | 查看授权范围 | +| `/openapi/partner/media/catalog-tree` | GET | 无 | 栏目树(按授权过滤) | +| `/openapi/partner/media/list` | GET | catalogid,pageindex,pagesize | 内容列表(按授权过滤) | +| `/openapi/partner/media/search` | GET | keyword,pageindex,pagesize | 搜索(按授权过滤) | + +### 3.2 天卡代销 `/openapi/partner/card` + +| 路径 | 方法 | 参数 | 说明 | +|------|------|------|------| +| `/openapi/partner/card/consume` | POST | body:{mac,deviceUid,deviceSn,channelOrgId} | 消耗天卡:分配1张绑定设备+异步激活 | +| `/openapi/partner/card/stock` | GET | 无 | 我的可用天卡库存 | + +**consume返回**: +```json +{"code":0,"msg":"","data":{"deviceId":123,"thirdCardNo":"xxx","cardCode":"xxx","expireAt":"...","status":"ACTIVE"}} +``` + +--- + +## 四、APK设备接口(设备sign鉴权) + +### 鉴权说明 +- **sign获取**:`POST /api/jst/init` 返回sign(在data内) +- **sign校验**:后续请求头携带 `sign` +- **免sign接口**:`POST /api/jst/init` +- **返回**:`{code:0, msg:"success", data:{...}}` + +### 4.1 设备接口 `/api/jst` + +| 路径 | 方法 | 鉴权 | 说明 | +|------|------|------|------| +| `/api/jst/init` | POST | 无 | 设备初始化,返回sign。**代销模式**:不传activationCode时按设备标识查找已注册设备 | +| `/api/jst/checkBenefits` | POST | sign | 查询天卡权益(ACTIVE返回thirdCardNo/UNBOUND) | +| `/api/jst/price` | POST | sign | 查询天卡价格(3层匹配) | +| `/api/jst/daycard/create` | POST | sign | 创建天卡订单(返回codeUrl二维码) | +| `/api/jst/daycard/status/{orderNo}` | GET | sign | 查询支付状态 | +| `/api/jst/heartBeat` | POST | sign | 心跳续期sign | +| `/api/jst/refreshSign` | POST | sign | 刷新sign(旧sign失效) | + +**init请求体**: +```json +{"activationCode":"激活码(代销模式不传)","mac":"设备MAC","deviceUid":"设备标识","deviceSn":"序列号"} +``` + +**init返回**: +```json +{"code":0,"msg":"success","data":{"device":{"deviceId":123,"roomNo":"001"},"sign":"xxx"}} +``` + +### 4.2 UI数据接口 `/api/jst` + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/api/jst/topButtons` | POST | 顶栏按钮(logo/搜索/帮助) | +| `/api/jst/filter/tags` | POST | 筛选分类(栏目/类型/地区/年代) | +| `/api/jst/filter/content` | POST | 筛选内容列表 | +| `/api/jst/config` | GET | 独立配置(logoUrl+loginBg) | + +### 4.3 APK调用流程 + +``` +正常模式:init(activationCode) -> checkBenefits -> 无权益则price+create+支付+status -> checkBenefits拿thirdCardNo播放 +代销模式:第三方consume消耗天卡 -> APK init(免activationCode,传设备标识) -> checkBenefits拿thirdCardNo播放 +``` + +--- + +## 五、支付回调接口(微信服务器调用) + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/openapi/payment/wechat/notify` | POST | 微信支付回调(验签+发卡) | +| `/openapi/payment/notify` | POST | 微信支付回调(旧路径兼容) | +| `/openapi/payment/refund/notify` | POST | 微信退款回调 | +| `/openapi/payment/wechat/profit-sharing/notify` | POST | 微信分账回调(明细级) | +| `/openapi/settle/notify` | POST | 微信分账回调(结算级) | + +--- + +## 六、路径变更对照表(前端适配用) + +| 旧路径 | 新路径 | +|--------|--------| +| `/sys/user/**` | `/manage/user/**` | +| `/sys/role/**` | `/manage/role/**` | +| `/sys/menu/**` | `/manage/menu/**` | +| `/sys/org/**` | `/manage/org/**` | +| `/sys/store/**` | `/manage/store/**` | +| `/sys/contract/**` | `/manage/contract/**` | +| `/sys/jetsen-media/**` | `/manage/jetsen-media/**` | +| `/openapi/partner/list` | `/manage/partner/list` | +| `/openapi/partner/info/**` | `/manage/partner/info/**` | +| `/openapi/partner/save` | `/manage/partner/save` | +| `/openapi/partner/update` | `/manage/partner/update` | +| `/openapi/partner/delete` | `/manage/partner/delete` | +| `/openapi/daycard/pool/**` | `/manage/daycard/pool/**`(已有,DayCardPoolController已删) | +| `/sys/login` | `/sys/login`(不变) | +| `/sys/refresh` | `/sys/refresh`(不变) | +| `/sys/logout` | `/sys/logout`(不变) | + +## 七、返回格式变更对照 + +| 接口类型 | 旧格式 | 新格式 | +|---------|--------|--------| +| APK设备 | `{code:200, msg:"success", data:{...}}` | `{code:0, msg:"success", data:{...}}` | +| APK UI | `{retCode:"00000000", data:{...}}` | `{code:0, msg:"success", data:{...}}` | +| APK init | `{code:200,...,data:{device:{}},sign:"xxx"}` | `{code:0,...,data:{device:{},sign:"xxx"}}` | +| 管理后台 | `{code:0, msg:"success", page:{...}}` | `{code:0, msg:"success", data:{page:{...}}}` | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9929f7d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM maven:3.9-eclipse-temurin-17 AS builder +WORKDIR /build +COPY pom.xml . +RUN mvn dependency:go-offline -B +COPY src ./src +RUN mvn clean package -DskipTests -B + +FROM eclipse-temurin:17-jre-alpine +LABEL maintainer="as-team" +WORKDIR /app +COPY --from=builder /build/target/daycard-openapi.jar /app/daycard-openapi.jar +EXPOSE 8084 +ENTRYPOINT ["java", "-jar", "/app/daycard-openapi.jar"] diff --git a/HANDOVER.md b/HANDOVER.md new file mode 100644 index 0000000..df5a343 --- /dev/null +++ b/HANDOVER.md @@ -0,0 +1,95 @@ +# 后端项目交接文档 + +## 项目信息 +- 路径:/Users/zhaoguodong/Desktop/as-daycard-openapi +- 启动:`./start.sh -Dspring-boot.run.profiles=dev`(端口8084,JDK17路径在start.sh里) +- 本地打包:`export JAVA_HOME=/Users/zhaoguodong/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home && mvn clean package -DskipTests` +- MySQL:/opt/homebrew/Cellar/mysql@8.0/8.0.42/bin/mysql -uroot -pr2356510 dev-0418 +- 接口文档:API_DOC.md(完整)+ THIRD_PARTY_API.md(第三方对接) +- DDL:db/full-schema.sql(52个表,完整表结构,从dev-0418导出) + +## 云服务器部署 +- 服务器:宝塔面板,IP见用户确认 +- 数据库:jstopenapi(已建库+导入full-schema.sql+schedule-timeout-task.sql) +- MySQL权限:设为"所有人"(允许Docker容器通过host.docker.internal访问) +- Docker部署:docker-compose.yml已配好(DB_URL/REDIS_HOST用host.docker.internal,JWT/API签名密钥已生成) +- application-prod.yml:已创建(和dev相同配置,环境变量由docker-compose传入) +- Dockerfile:已改为multi-stage(JDK17),但服务器上用的是简单版(本地打包jar上传+COPY) +- 部署方式:本地mvn打包 -> scp上传jar -> docker-compose build/up +- **已知问题**:MySQL的max_connect_errors封锁Docker容器IP,需 `mysql -uroot -p -e "FLUSH HOSTS;"` 解除后重启容器 +- **部署状态**:jar已上传,application-prod.yml已包含,正在解决MySQL连接封锁问题 + +## 已完成的功能 + +### 1. 橘视厅媒资接口(/manage/jetsen-media/**,JWT鉴权) +- catalog-tree/list/search/export/nav/tags +- 栏目树(一级+二级),list必须用二级栏目id(如472=3221条) +- orderinfo:"1"必须带 + +### 2. 第三方开放接口(/openapi/partner/**,appKey签名鉴权) +- PartnerSignFilter:MD5(appKey+timestamp+nonce+appSecret).toUpperCase(),5分钟有效+nonce防重放 +- 媒资接口(/openapi/partner/media/):package/catalog-tree/list/search +- 三级授权:CONTENT(单部内容)>CATALOG(栏目)>FULL(全量),按open_api_content_package配置判定 +- 天卡代销(/openapi/partner/card/):consume(消耗天卡绑定设备)/stock(库存) +- 调用日志:open_api_call_log + +### 3. 代销模式 +- 采购:POST /manage/partner-card/purchase(平台卡池UNUSED->RESERVED,记入partner池子AVAILABLE) +- 消耗:POST /openapi/partner/card/consume(partner池子取1张,绑定设备,RESERVED->USED,异步激活橘视厅) +- 回退:POST /manage/partner-card/revert(AVAILABLE的天卡回退,RESERVED->UNUSED,partner池子DISABLED) +- 免activationCode init:/api/jst/init不传activationCode时按设备标识查找已注册设备(代销模式) +- APK流程:consume消耗天卡 -> APK init(免activationCode) -> checkBenefits拿thirdCardNo -> 播放 + +### 4. 内容包管理(/manage/content-package/**,JWT鉴权) +- CRUD + fetch-contents(从橘视厅拉内容预览)+ save-from-selection(勾选内容一键保存) +- info/info-by-partner返回contentList(解析后的内容数组) +- save-from-selection支持传id覆盖更新 + +### 5. 接口风格统一 +- 所有接口返回:{code:0, msg:"success", data:{...}} +- APK设备接口原code:200已改0,sign从顶层移入data +- UI接口原retCode:"00000000"已改{code:0,msg,data} +- 管理后台R.ok().put()全部改R.dataSuccess()(数据在data内) +- 路径统一:/sys/管理接口移到/manage/(仅login/refresh/logout保留/sys/) +- 合作伙伴管理从/openapi/partner移到/manage/partner(JWT鉴权) +- 删除冗余DayCardPoolController(和DayCardManageController冲突) + +### 6. 审计日志 +- audit_log表已重建(字段对齐AuditLogEntity) +- 46个@AuditAction审计点(资金/资产/设备/组织写操作) +- AuditLogAspect切面自动记录 + +## 数据库关键表 +- 合作伙伴:open_api_partner(appKey/appSecret) +- 内容包:open_api_content_package(partnerId/catalogIds/contentCodes) +- partner天卡池:open_api_partner_card_pool(partnerId/poolId/cardCode/status) +- 调用日志:open_api_call_log +- 审计日志:audit_log(已重建) +- 天卡池:day_card_pool(UNUSED->RESERVED->USED->REUSABLE) +- 设备:device(含androidId/manufacturer/model/brand扩展字段) + +## 测试验证状态 +- ✅ 媒资接口(4个)+ appKey签名鉴权 + 三级授权过滤 +- ✅ 采购天卡 + 消耗天卡 + 回退天卡 +- ✅ 内容包配置(fetch-contents + save-from-selection) +- ✅ 禁用合作伙伴 -> 鉴权失败 +- ✅ 删除内容包 -> FULL全量fallback +- ✅ 管理后台前端已适配新路径和返回格式 + +## 待处理事项 +1. **服务器部署**:解决MySQL连接封锁后重启Docker容器,验证启动成功 +2. **直营模式**(方式2):EPG调开放订单接口 -> 微信支付 -> 自动发卡(未开发) +3. **旧代码清理**:DayCardSettleController(旧分账)/MediaController(桩)/ContractServiceImpl(桩)等废弃代码 +4. **Nginx反代+HTTPS**:宝塔配反代,第三方用域名调用 +5. **前端菜单**:合作伙伴管理/内容包管理/天卡池管理的菜单页面 + +## 关键配置 +- docker-compose.yml:DB_URL=jstopenapi, REDIS_HOST=host.docker.internal, JWT_SECRET=2F064BA10037477FBB3A1BF9D508AE96, API_SIGN_SECRET=5F2A2DD8BFBA4DA1B87EFBB405671B3D +- application-prod.yml:和dev相同,环境变量传入 +- Druid wall filter:dev和prod都禁用(不兼容SKIP LOCKED) +- Lombok:1.18.24,必须用JDK17编译(JDK21+不兼容) + +## 合作伙伴测试数据(本地dev-0418) +- 华住酒店:partnerId=0f91ea23961e4983, appKey=b9f9542e1e044466, appSecret=d117d67429b941c1bc0e0f6044e39d62 +- 测试合作伙伴:partnerId=1, appKey=71df57e7e47a4a88, appSecret=512f147432124bb48b313365ee31b87b +- 内容包:id=3, name=高分3部, catalogIds=472, contentCount=3, CONTENT级别 diff --git a/db/add_device_source_field.sql b/db/add_device_source_field.sql new file mode 100644 index 0000000..94eec19 --- /dev/null +++ b/db/add_device_source_field.sql @@ -0,0 +1,7 @@ +-- 设备表增加来源标识字段(在云服务器数据库 jstopenapi 上执行) + +ALTER TABLE device ADD COLUMN source VARCHAR(20) DEFAULT 'DIRECT' COMMENT '设备来源: DIRECT(直销)/PARTNER(代销)'; +ALTER TABLE device ADD COLUMN partner_id VARCHAR(32) DEFAULT NULL COMMENT '合作伙伴ID(代销模式)'; + +-- 验证 +DESCRIBE device; diff --git a/db/audit_log_rebuild.sql b/db/audit_log_rebuild.sql new file mode 100644 index 0000000..6df6df9 --- /dev/null +++ b/db/audit_log_rebuild.sql @@ -0,0 +1,22 @@ +DROP TABLE IF EXISTS audit_log; +CREATE TABLE audit_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + actor_user_id BIGINT DEFAULT NULL COMMENT '操作人用户ID', + head VARCHAR(256) DEFAULT NULL COMMENT '操作标题(中文描述)', + method VARCHAR(128) DEFAULT NULL COMMENT '动作类型(如ACTCODE_GENERATE)', + api VARCHAR(256) DEFAULT NULL COMMENT 'API路径(类名#方法名)', + param VARCHAR(2000) DEFAULT NULL COMMENT '入参JSON', + result VARCHAR(500) DEFAULT NULL COMMENT '返回结果/异常', + `before` TEXT DEFAULT NULL COMMENT '变更前数据', + `after` TEXT DEFAULT NULL COMMENT '变更后数据', + reason VARCHAR(512) DEFAULT NULL COMMENT '操作原因', + ip VARCHAR(64) DEFAULT NULL COMMENT '请求IP', + device_info VARCHAR(255) DEFAULT NULL COMMENT 'User-Agent/设备信息', + ticket_no VARCHAR(64) DEFAULT NULL COMMENT '工单号', + created_at DATETIME DEFAULT NULL COMMENT '创建时间', + updated_at DATETIME DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (id), + KEY idx_actor_user_id (actor_user_id), + KEY idx_created_at (created_at), + KEY idx_method (method) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审计日志'; diff --git a/db/device-info-fields.sql b/db/device-info-fields.sql new file mode 100644 index 0000000..c1d409f --- /dev/null +++ b/db/device-info-fields.sql @@ -0,0 +1,12 @@ +-- =========================================================== +-- device-info-fields.sql +-- init 接口扩展设备信息字段(对齐 App 端 20260716 需求) +-- 新增 android_id / manufacturer / model / brand +-- =========================================================== + +ALTER TABLE `device` + ADD COLUMN `android_id` VARCHAR(64) DEFAULT NULL COMMENT 'Android ID(重装不变,工厂重置才变)' AFTER `device_uid`, + ADD COLUMN `manufacturer` VARCHAR(64) DEFAULT NULL COMMENT '设备厂商 Build.MANUFACTURER' AFTER `android_id`, + ADD COLUMN `model` VARCHAR(128) DEFAULT NULL COMMENT '设备型号 Build.MODEL' AFTER `manufacturer`, + ADD COLUMN `brand` VARCHAR(64) DEFAULT NULL COMMENT '设备品牌 Build.BRAND' AFTER `model`, + ADD INDEX `idx_android_id` (`android_id`); diff --git a/db/device-tables.sql b/db/device-tables.sql new file mode 100644 index 0000000..859a2bc --- /dev/null +++ b/db/device-tables.sql @@ -0,0 +1,51 @@ +-- ============================================================ +-- 补建 device + card_device_binding 表(mysql.sql 遗漏,含扩展字段) +-- 全新部署时执行此文件后,无需再执行 device-info-fields.sql +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `device` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `store_org_id` BIGINT DEFAULT NULL COMMENT '门店组织ID', + `mac` VARCHAR(64) DEFAULT NULL COMMENT '设备MAC', + `device_sn` VARCHAR(64) DEFAULT NULL COMMENT '设备序列号', + `wifi_mac` VARCHAR(64) DEFAULT NULL COMMENT 'WiFi MAC', + `device_uid` VARCHAR(64) DEFAULT NULL COMMENT '设备唯一标识', + `android_id` VARCHAR(64) DEFAULT NULL COMMENT 'Android ID(重装不变,工厂重置才变)', + `manufacturer` VARCHAR(64) DEFAULT NULL COMMENT '设备厂商 Build.MANUFACTURER', + `model` VARCHAR(128) DEFAULT NULL COMMENT '设备型号 Build.MODEL', + `brand` VARCHAR(64) DEFAULT NULL COMMENT '设备品牌 Build.BRAND', + `identifier_strategy` VARCHAR(32) DEFAULT NULL COMMENT '标识匹配策略', + `device_name` VARCHAR(128) DEFAULT NULL COMMENT '设备名称', + `device_model` VARCHAR(128) DEFAULT NULL COMMENT '设备型号(详细)', + `ver_code` VARCHAR(32) DEFAULT NULL COMMENT '版本号', + `ver_name` VARCHAR(64) DEFAULT NULL COMMENT '版本名', + `android_version` VARCHAR(32) DEFAULT NULL COMMENT '安卓版本', + `room_no` VARCHAR(32) DEFAULT NULL COMMENT '房间号', + `last_heartbeat` DATETIME DEFAULT NULL COMMENT '最后心跳时间', + `remark` VARCHAR(256) DEFAULT NULL COMMENT '备注', + `status` VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/DISABLED', + `delete_flag` VARCHAR(8) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL', + `created_at` DATETIME DEFAULT NULL COMMENT '创建时间', + `updated_at` DATETIME DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_mac` (`mac`), + KEY `idx_device_uid` (`device_uid`), + KEY `idx_device_sn` (`device_sn`), + KEY `idx_android_id` (`android_id`), + KEY `idx_store_org_id` (`store_org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备表'; + +CREATE TABLE IF NOT EXISTS `card_device_binding` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `card_id` BIGINT DEFAULT NULL COMMENT '卡ID', + `device_id` BIGINT DEFAULT NULL COMMENT '设备ID', + `bind_source` VARCHAR(32) DEFAULT NULL COMMENT '绑定来源', + `bind_at` DATETIME DEFAULT NULL COMMENT '绑定时间', + `unbind_at` DATETIME DEFAULT NULL COMMENT '解绑时间', + `status` VARCHAR(16) NOT NULL DEFAULT 'BOUND' COMMENT '绑定状态:BOUND/UNBOUND/STANDBY/FAILURE', + `created_at` DATETIME DEFAULT NULL COMMENT '创建时间', + `updated_at` DATETIME DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_device_id` (`device_id`), + KEY `idx_card_id` (`card_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='卡与设备绑定'; diff --git a/db/full-schema.sql b/db/full-schema.sql new file mode 100644 index 0000000..e4863db --- /dev/null +++ b/db/full-schema.sql @@ -0,0 +1,1013 @@ + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; +DROP TABLE IF EXISTS `api_jst_call_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `api_jst_call_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `api_path` varchar(2000) DEFAULT NULL COMMENT '接口路径', + `request_method` varchar(20) DEFAULT 'POST' COMMENT '请求方法', + `request_body` text COMMENT '请求体', + `response_code` int DEFAULT NULL COMMENT '响应HTTP码', + `response_body` text COMMENT '响应体', + `success` tinyint DEFAULT NULL COMMENT '是否成功 0失败 1成功', + `duration` int DEFAULT NULL COMMENT '耗时毫秒', + `data_id` bigint DEFAULT NULL COMMENT '关联数据ID(如device.id)', + `request_headers` varchar(2000) DEFAULT NULL COMMENT '请求自定义header(JSON)', + `client_ip` varchar(64) DEFAULT NULL COMMENT '客户端IP', + `error_message` varchar(500) DEFAULT NULL COMMENT '错误信息', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_data_id` (`data_id`), + KEY `idx_create_time` (`create_time`) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='橘视厅/聚视厅接口调用日志'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `audit_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `audit_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `actor_user_id` bigint DEFAULT NULL COMMENT '操作人用户ID', + `head` varchar(256) DEFAULT NULL COMMENT '操作标题(中文描述)', + `method` varchar(128) DEFAULT NULL COMMENT '动作类型(如ACTCODE_GENERATE)', + `api` varchar(256) DEFAULT NULL COMMENT 'API路径(类名#方法名)', + `param` varchar(2000) DEFAULT NULL COMMENT '入参JSON', + `result` varchar(500) DEFAULT NULL COMMENT '返回结果/异常', + `before` text COMMENT '变更前数据', + `after` text COMMENT '变更后数据', + `reason` varchar(512) DEFAULT NULL COMMENT '操作原因', + `ip` varchar(64) DEFAULT NULL COMMENT '请求IP', + `device_info` varchar(255) DEFAULT NULL COMMENT 'User-Agent/设备信息', + `ticket_no` varchar(64) DEFAULT NULL COMMENT '工单号', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_actor_user_id` (`actor_user_id`), + KEY `idx_created_at` (`created_at`), + KEY `idx_method` (`method`) +) ENGINE=InnoDB AUTO_INCREMENT=23 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='审计日志'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `card`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `card` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `card_no` varchar(100) DEFAULT NULL, + `card_code` varchar(100) DEFAULT NULL, + `third_party_ref` varchar(200) DEFAULT NULL, + `third_card_no` varchar(200) DEFAULT NULL, + `card_type` varchar(20) DEFAULT NULL, + `supplier_id` bigint DEFAULT NULL, + `inventory_status` varchar(30) DEFAULT NULL, + `allocation_scene` varchar(30) DEFAULT NULL, + `usage_status` varchar(30) DEFAULT NULL, + `contract_activation_code_id` bigint DEFAULT NULL, + `org_id` bigint DEFAULT NULL, + `allocation_order_id` bigint DEFAULT NULL, + `import_batch_id` bigint DEFAULT NULL, + `third_import_batch` varchar(100) DEFAULT NULL, + `price` decimal(10,2) DEFAULT NULL, + `delete_flag` varchar(10) DEFAULT NULL, + `expire_at` datetime DEFAULT NULL, + `third_start_at` datetime DEFAULT NULL, + `third_expire_at` datetime DEFAULT NULL, + `active_at` datetime DEFAULT NULL, + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `card_device_binding`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `card_device_binding` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `card_id` bigint DEFAULT NULL COMMENT '卡ID', + `card_code` varchar(128) DEFAULT NULL COMMENT '卡密', + `device_id` bigint DEFAULT NULL COMMENT '设备ID', + `bind_source` varchar(32) DEFAULT NULL COMMENT '绑定来源:DAY_CARD/YEAR_CARD/SEASON_CARD', + `status` varchar(20) DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_device_id` (`device_id`), + KEY `idx_card_id` (`card_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='卡设备绑定表'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `card_transfer`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `card_transfer` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `card_id` bigint DEFAULT NULL, + `from_org_id` bigint DEFAULT NULL, + `to_org_id` bigint DEFAULT NULL, + `action_type` varchar(30) DEFAULT NULL, + `delete_flag` varchar(10) DEFAULT NULL, + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `contract`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `contract` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `store_org_id` bigint DEFAULT NULL, + `contract_no` varchar(50) DEFAULT NULL, + `status` varchar(20) DEFAULT NULL, + `delete_flag` varchar(10) DEFAULT NULL, + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `contract_activation_code`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `contract_activation_code` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `contract_id` bigint DEFAULT NULL, + `code` varchar(100) DEFAULT NULL, + `card_type` varchar(20) DEFAULT NULL, + `status` varchar(20) DEFAULT NULL, + `delete_flag` varchar(10) DEFAULT NULL, + `store_org_id` bigint DEFAULT NULL, + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `day_card_activation_code`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `day_card_activation_code` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `code` varchar(32) NOT NULL COMMENT '激活码(唯一)', + `channel_org_id` bigint NOT NULL COMMENT '渠道组织ID', + `store_org_id` bigint NOT NULL COMMENT '门店组织ID', + `status` varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/DISABLED', + `max_device_count` int NOT NULL DEFAULT '0' COMMENT '最大可激活设备数(0=不限)', + `activated_count` int NOT NULL DEFAULT '0' COMMENT '已激活设备数', + `remark` varchar(255) DEFAULT NULL COMMENT '备注', + `delete_flag` varchar(10) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_code` (`code`), + KEY `idx_store_org_id` (`store_org_id`), + KEY `idx_channel_org_id` (`channel_org_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='天卡设备激活码'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `day_card_import_batch`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `day_card_import_batch` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `batch_no` varchar(64) NOT NULL COMMENT '批次号', + `partner_id` bigint DEFAULT NULL COMMENT '合作伙伴ID', + `status` varchar(20) NOT NULL DEFAULT 'PROCESSING' COMMENT '状态:PROCESSING/SUCCEEDED/PARTIAL_FAILED/FAILED', + `total_count` int NOT NULL DEFAULT '0' COMMENT '总数量', + `success_count` int NOT NULL DEFAULT '0' COMMENT '成功数量', + `fail_count` int NOT NULL DEFAULT '0' COMMENT '失败数量', + `result_report_url` varchar(500) DEFAULT NULL COMMENT '结果报告URL', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + `created_by` bigint DEFAULT NULL COMMENT '创建人', + `delete_flag` varchar(10) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_batch_no` (`batch_no`), + KEY `idx_partner_id` (`partner_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='天卡导入批次'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `day_card_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `day_card_order` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `order_no` varchar(32) NOT NULL COMMENT '订单号', + `partner_id` bigint DEFAULT NULL COMMENT '合作伙伴ID', + `device_id` bigint DEFAULT NULL COMMENT '设备ID', + `store_org_id` bigint DEFAULT NULL COMMENT '门店组织ID', + `channel_org_id` bigint DEFAULT NULL COMMENT '渠道组织ID', + `card_no` varchar(128) DEFAULT NULL COMMENT '卡号(支付后写入,关联day_card_pool.card_code)', + `status` varchar(20) NOT NULL DEFAULT 'CREATED' COMMENT '订单状态:CREATED/PAYING/PAID/PAY_FAILED/CANCELLED', + `amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '订单金额(元)', + `pay_no` varchar(64) DEFAULT NULL COMMENT '支付流水号', + `pay_amount` decimal(10,2) DEFAULT NULL COMMENT '实际支付金额(元)', + `pay_time` datetime DEFAULT NULL COMMENT '支付时间', + `expire_time` datetime DEFAULT NULL COMMENT '订单过期时间(15分钟)', + `cancel_time` datetime DEFAULT NULL COMMENT '取消时间', + `delete_flag` varchar(10) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `pay_channel` varchar(20) DEFAULT 'MOCK' COMMENT '支付渠道 MOCK/WECHAT', + `prepay_id` varchar(64) DEFAULT NULL COMMENT '微信预支付ID', + `code_url` varchar(500) DEFAULT NULL COMMENT '微信扫码支付链接', + `device_sn` varchar(128) DEFAULT NULL COMMENT '设备SN(冗余)', + `mac` varchar(64) DEFAULT NULL COMMENT '设备MAC(冗余)', + `agent_org_id` bigint DEFAULT NULL COMMENT '代理商组织ID', + `org_path_snapshot` varchar(512) DEFAULT NULL COMMENT '组织链路快照(如 1.5.12.)', + `channel_order_no` varchar(128) DEFAULT NULL COMMENT '微信支付订单号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_order_no` (`order_no`), + KEY `idx_device_status` (`device_id`,`status`), + KEY `idx_status_expire` (`status`,`expire_time`), + KEY `idx_partner_status` (`partner_id`,`status`) +) ENGINE=InnoDB AUTO_INCREMENT=33 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='天卡订单表'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `day_card_pool`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `day_card_pool` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `card_code` varchar(128) NOT NULL COMMENT '卡密(导入的激活码)', + `third_party_ref` varchar(128) DEFAULT NULL COMMENT '第三方引用标识', + `third_card_no` varchar(128) DEFAULT NULL COMMENT '传第三方的唯一卡号', + `card_type` varchar(20) NOT NULL DEFAULT 'DAY' COMMENT '卡类型:DAY', + `partner_id` bigint DEFAULT NULL COMMENT '所属合作伙伴ID', + `status` varchar(20) NOT NULL DEFAULT 'UNUSED' COMMENT '状态:UNUSED/RESERVED/USED', + `activate_status` varchar(20) DEFAULT 'PENDING' COMMENT '激活状态 PENDING/SUCCESS/FAILED', + `activate_error` varchar(1000) DEFAULT NULL COMMENT '激活失败原因', + `activated_at` datetime DEFAULT NULL COMMENT '激活成功时间', + `order_no` varchar(32) DEFAULT NULL COMMENT '关联订单号', + `device_id` bigint DEFAULT NULL COMMENT '关联设备ID', + `import_batch_id` bigint DEFAULT NULL COMMENT '导入批次ID', + `expire_at` datetime DEFAULT NULL COMMENT '卡过期时间', + `used_at` datetime DEFAULT NULL COMMENT '使用时间', + `delete_flag` varchar(10) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_card_code` (`card_code`), + KEY `idx_partner_status` (`partner_id`,`status`), + KEY `idx_order_no` (`order_no`), + KEY `idx_device_id` (`device_id`), + KEY `idx_import_batch_id` (`import_batch_id`) +) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='天卡池'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `day_card_price_config`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `day_card_price_config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `partner_id` bigint NOT NULL COMMENT '合作伙伴ID', + `channel_org_id` bigint DEFAULT NULL COMMENT '渠道组织ID(可空=全渠道默认)', + `store_org_id` bigint DEFAULT NULL COMMENT '门店组织ID(空=渠道级/平台级)', + `price` decimal(10,2) NOT NULL COMMENT '天卡价格(元)', + `name` varchar(100) DEFAULT NULL COMMENT '策略名称', + `status` varchar(20) DEFAULT 'ACTIVE' COMMENT '状态 ACTIVE/DISABLED', + `delete_flag` varchar(10) DEFAULT 'UNDEL', + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_partner_channel` (`partner_id`,`channel_org_id`), + KEY `idx_store_org_id` (`store_org_id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='天卡定价策略'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `day_card_profit_receiver`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `day_card_profit_receiver` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `partner_id` bigint NOT NULL COMMENT '合作伙伴ID', + `receiver_type` varchar(20) NOT NULL COMMENT '接收方类型 MERCHANT_ID/PERSONAL_OPENID', + `receiver_account` varchar(128) NOT NULL COMMENT '接收方账户(商户号或openid)', + `receiver_name` varchar(128) DEFAULT NULL COMMENT '接收方名称', + `relation_type` varchar(20) DEFAULT 'PARTNER' COMMENT '关系类型 PARTNER/STORE/CHANNEL', + `relation_org_id` bigint DEFAULT NULL COMMENT '关联组织ID', + `wx_bind_status` varchar(20) DEFAULT 'PENDING' COMMENT '微信绑定状态 PENDING/BINDED/FAILED', + `wx_bind_error` varchar(500) DEFAULT NULL COMMENT '绑定失败原因', + `status` varchar(20) DEFAULT 'ACTIVE' COMMENT '状态 ACTIVE/DISABLED', + `remark` varchar(500) DEFAULT NULL, + `delete_flag` varchar(10) DEFAULT 'UNDEL', + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_partner_account_type` (`partner_id`,`receiver_account`,`receiver_type`), + KEY `idx_partner_id` (`partner_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='分账接收方'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `day_card_profit_rule`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `day_card_profit_rule` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL COMMENT '规则名称', + `partner_id` bigint NOT NULL COMMENT '合作伙伴ID', + `channel_org_id` bigint DEFAULT NULL COMMENT '渠道组织ID(空=合作伙伴级)', + `store_org_id` bigint DEFAULT NULL COMMENT '门店组织ID(空=渠道级)', + `receiver_type` varchar(20) DEFAULT 'MERCHANT_ID' COMMENT '接收方类型 MERCHANT_ID/PERSONAL_OPENID', + `receiver_account` varchar(128) NOT NULL COMMENT '分账接收方账户', + `receiver_name` varchar(128) DEFAULT NULL COMMENT '接收方名称', + `share_ratio` decimal(5,4) NOT NULL COMMENT '分账比例(0.0001~0.3000,最大30%)', + `share_type` varchar(20) DEFAULT 'RATIO' COMMENT '分账类型 RATIO/FIXED', + `priority` int DEFAULT '0' COMMENT '优先级(数字越大越优先)', + `status` varchar(20) DEFAULT 'ACTIVE' COMMENT '状态 ACTIVE/DISABLED', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + `delete_flag` varchar(10) DEFAULT 'UNDEL', + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_partner_channel_store` (`partner_id`,`channel_org_id`,`store_org_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='天卡分润规则'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `day_card_refund`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `day_card_refund` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `refund_no` varchar(64) NOT NULL COMMENT '退款单号', + `order_no` varchar(64) NOT NULL COMMENT '关联订单号', + `wx_refund_id` varchar(128) DEFAULT NULL COMMENT '微信退款单号', + `refund_amount` decimal(10,2) NOT NULL COMMENT '退款金额', + `reason` varchar(255) DEFAULT NULL COMMENT '退款原因', + `status` varchar(20) DEFAULT 'PENDING' COMMENT '状态 PENDING/PROCESSING/SUCCESS/FAILED/CLOSED', + `error_message` varchar(500) DEFAULT NULL, + `settled_at` datetime DEFAULT NULL COMMENT '退款完成时间', + `delete_flag` varchar(10) DEFAULT 'UNDEL', + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_refund_no` (`refund_no`), + KEY `idx_order_no` (`order_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='天卡退款记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `day_card_settle_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `day_card_settle_order` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `settle_no` varchar(64) NOT NULL COMMENT '分账单号', + `order_no` varchar(64) NOT NULL COMMENT '关联天卡订单号', + `profit_rule_id` bigint DEFAULT NULL COMMENT '匹配的分润规则ID', + `receiver_account` varchar(128) NOT NULL COMMENT '接收方账户', + `receiver_type` varchar(20) DEFAULT 'MERCHANT_ID' COMMENT '接收方类型', + `total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额', + `share_ratio` decimal(5,4) NOT NULL COMMENT '分账比例', + `settle_amount` decimal(10,2) NOT NULL COMMENT '分账金额', + `wx_settle_id` varchar(128) DEFAULT NULL COMMENT '微信分账单号', + `status` varchar(20) DEFAULT 'PENDING' COMMENT '状态 PENDING/PROCESSING/SUCCESS/FAILED', + `error_message` varchar(500) DEFAULT NULL, + `settled_at` datetime DEFAULT NULL COMMENT '分账完成时间', + `retry_count` int DEFAULT '0' COMMENT '重试次数', + `delete_flag` varchar(10) DEFAULT 'UNDEL', + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_settle_no` (`settle_no`), + KEY `idx_order_no` (`order_no`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='天卡分账单'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `day_card_settle_record`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `day_card_settle_record` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `order_no` varchar(64) NOT NULL COMMENT '订单号', + `settle_rule_id` bigint DEFAULT NULL COMMENT '分账规则ID', + `total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额', + `share_ratio` decimal(5,4) NOT NULL COMMENT '分账比例', + `settle_amount` decimal(10,2) NOT NULL COMMENT '分账金额', + `receiver_account` varchar(128) DEFAULT NULL COMMENT '接收方账户', + `status` varchar(20) DEFAULT 'PENDING' COMMENT '状态 PENDING/SUCCESS/FAILED', + `error_message` varchar(500) DEFAULT NULL, + `settled_at` datetime DEFAULT NULL COMMENT '分账完成时间', + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_order_no` (`order_no`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='天卡分账记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `day_card_settle_rule`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `day_card_settle_rule` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL COMMENT '规则名称', + `partner_id` bigint DEFAULT NULL COMMENT '合作伙伴ID', + `channel_org_id` bigint DEFAULT NULL COMMENT '渠道组织ID', + `store_org_id` bigint DEFAULT NULL COMMENT '门店组织ID(空=渠道级)', + `receiver_account` varchar(128) DEFAULT NULL COMMENT '分账接收方账户', + `receiver_name` varchar(128) DEFAULT NULL COMMENT '接收方名称', + `share_ratio` decimal(5,4) NOT NULL COMMENT '分账比例(如0.3000=30%)', + `receiver_type` varchar(20) DEFAULT 'MERCHANT_ID' COMMENT '接收方类型 MERCHANT_ID/PERSONAL_OPENID', + `status` varchar(20) DEFAULT 'ACTIVE' COMMENT '状态 ACTIVE/DISABLED', + `delete_flag` varchar(10) DEFAULT 'UNDEL', + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_partner_channel` (`partner_id`,`channel_org_id`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='天卡分账规则'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `device`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `device` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `store_org_id` bigint DEFAULT NULL, + `mac` varchar(64) DEFAULT NULL, + `device_sn` varchar(128) DEFAULT NULL, + `wifi_mac` varchar(64) DEFAULT NULL, + `device_uid` varchar(128) DEFAULT NULL, + `android_id` varchar(64) DEFAULT NULL COMMENT 'Android ID(重装不变,工厂重置才变)', + `manufacturer` varchar(64) DEFAULT NULL COMMENT '设备厂商 Build.MANUFACTURER', + `model` varchar(128) DEFAULT NULL COMMENT '设备型号 Build.MODEL', + `brand` varchar(64) DEFAULT NULL COMMENT '设备品牌 Build.BRAND', + `identifier_strategy` varchar(32) DEFAULT NULL, + `device_name` varchar(128) DEFAULT NULL, + `device_model` varchar(64) DEFAULT NULL, + `ver_code` varchar(32) DEFAULT NULL, + `ver_name` varchar(64) DEFAULT NULL, + `android_version` varchar(32) DEFAULT NULL, + `room_no` varchar(64) DEFAULT NULL, + `last_heartbeat` datetime DEFAULT NULL, + `remark` varchar(255) DEFAULT NULL, + `status` varchar(20) DEFAULT 'ACTIVE', + `delete_flag` varchar(10) DEFAULT 'UNDEL', + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_android_id` (`android_id`) +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='设备表'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `open_api_call_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `open_api_call_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `partner_id` varchar(32) DEFAULT NULL COMMENT '合作伙伴ID', + `app_key` varchar(32) DEFAULT NULL, + `api` varchar(256) DEFAULT NULL COMMENT '请求路径', + `http_method` varchar(16) DEFAULT NULL, + `params` varchar(2000) DEFAULT NULL COMMENT '请求参数', + `result_code` int DEFAULT NULL COMMENT '返回业务code', + `ip` varchar(64) DEFAULT NULL, + `cost_ms` int DEFAULT NULL COMMENT '耗时毫秒', + `created_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_partner_id` (`partner_id`), + KEY `idx_created_at` (`created_at`) +) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='开放API调用日志'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `open_api_content_package`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `open_api_content_package` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `partner_id` varchar(32) NOT NULL COMMENT '合作伙伴ID', + `name` varchar(128) DEFAULT NULL COMMENT '内容包名称', + `catalog_ids` varchar(2000) DEFAULT NULL COMMENT '授权栏目ID列表(逗号分隔,二级栏目id如472)', + `content_codes` text COMMENT '授权内容code列表(逗号分隔),非空时优先按内容过滤', + `status` varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/DISABLED', + `delete_flag` varchar(8) NOT NULL DEFAULT 'UNDEL', + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_partner_id` (`partner_id`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='开放API内容包授权'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `open_api_partner`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `open_api_partner` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `partner_id` varchar(64) NOT NULL COMMENT '合作伙伴ID', + `app_key` varchar(64) NOT NULL COMMENT '应用Key', + `app_secret` varchar(128) NOT NULL COMMENT '应用密钥', + `name` varchar(100) DEFAULT NULL COMMENT '合作伙伴名称', + `status` varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/DISABLED', + `api_permissions` text COMMENT 'API权限(JSON)', + `rate_limit` int NOT NULL DEFAULT '100' COMMENT '速率限制(每秒请求数)', + `delete_flag` varchar(10) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_app_key` (`app_key`), + UNIQUE KEY `uk_partner_id` (`partner_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='开放API合作伙伴'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `open_api_partner_card_pool`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `open_api_partner_card_pool` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `partner_id` varchar(32) NOT NULL COMMENT '合作伙伴ID(open_api_partner.partner_id)', + `pool_id` bigint NOT NULL COMMENT 'day_card_pool.id 天卡池主键', + `card_code` varchar(64) DEFAULT NULL COMMENT '天卡卡密', + `third_card_no` varchar(64) DEFAULT NULL COMMENT '橘视厅第三方卡号', + `status` varchar(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE可用/CONSUMED已消耗/DISABLED已禁用', + `device_id` bigint DEFAULT NULL COMMENT '消耗后绑定的设备ID', + `channel_org_id` bigint DEFAULT NULL COMMENT '渠道组织ID(sys_org.org_id)', + `purchase_batch` varchar(32) DEFAULT NULL COMMENT '采购批次号', + `consumed_at` datetime DEFAULT NULL COMMENT '消耗时间', + `delete_flag` varchar(8) NOT NULL DEFAULT 'UNDEL', + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_partner_id` (`partner_id`), + KEY `idx_partner_status` (`partner_id`,`status`), + KEY `idx_pool_id` (`pool_id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='合作伙伴天卡池(代销模式)'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `org_settlement_account`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `org_settlement_account` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL COMMENT '关联组织', + `pay_channel` varchar(32) DEFAULT 'WECHAT' COMMENT '支付渠道', + `receiver_type` varchar(32) NOT NULL COMMENT 'MERCHANT_ID / PERSONAL_OPENID', + `receiver_account` varchar(128) NOT NULL COMMENT '商户号或OpenID', + `receiver_name` varchar(128) DEFAULT NULL COMMENT '收款方名称', + `bind_status` varchar(16) DEFAULT 'PENDING' COMMENT 'PENDING/ACTIVE/FAILED', + `external_bind_id` varchar(128) DEFAULT NULL COMMENT '微信侧绑定记录ID', + `delete_flag` varchar(8) DEFAULT 'UNDEL', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_org` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='组织结算账户表(分账接收方)'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `profit_sharing_detail`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `profit_sharing_detail` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `sharing_order_id` bigint NOT NULL COMMENT '关联分账单', + `org_id` bigint DEFAULT NULL COMMENT '接收方组织ID', + `org_role` varchar(32) DEFAULT NULL COMMENT 'PLATFORM/CHANNEL/AGENT/STORE', + `receiver_account` varchar(128) DEFAULT NULL COMMENT '接收方账号', + `receiver_type` varchar(32) DEFAULT NULL COMMENT '接收方类型', + `amount` int NOT NULL COMMENT '分账金额(分)', + `description` varchar(128) DEFAULT NULL COMMENT '分账描述', + `status` varchar(16) DEFAULT 'PENDING' COMMENT 'PENDING/SUCCESS/FAILED', + `external_order_id` varchar(128) DEFAULT NULL COMMENT '微信侧分账单号', + `success_time` datetime DEFAULT NULL COMMENT '成功时间', + `fail_reason` varchar(512) DEFAULT NULL COMMENT '失败原因', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sharing_order` (`sharing_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='分账明细表'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `profit_sharing_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `profit_sharing_order` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `pay_order_no` varchar(64) NOT NULL COMMENT '关联支付订单号', + `transaction_id` varchar(64) DEFAULT NULL COMMENT '微信支付订单号', + `out_order_no` varchar(64) NOT NULL COMMENT '分账方内部单号(唯一)', + `status` varchar(16) DEFAULT 'PENDING' COMMENT 'PENDING/SUCCESS/FAILED/PARTIAL', + `total_amount` int DEFAULT NULL COMMENT '分账总金额(分)', + `rule_id` bigint DEFAULT NULL COMMENT '命中的规则ID', + `rule_snapshot` text COMMENT '规则快照(JSON)', + `org_path_snapshot` varchar(512) DEFAULT NULL COMMENT '组织链路快照', + `sharing_channel` varchar(32) DEFAULT 'WECHAT_NATIVE' COMMENT 'WECHAT_NATIVE/AGGREGATE', + `delete_flag` varchar(8) DEFAULT 'UNDEL', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_out_order` (`out_order_no`), + KEY `idx_pay_order` (`pay_order_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='分账单表'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `profit_sharing_rule`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `profit_sharing_rule` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `rule_name` varchar(128) NOT NULL COMMENT '规则名称', + `scope_type` varchar(32) NOT NULL COMMENT '适用范围:GLOBAL/CHANNEL/AGENT/STORE', + `scope_org_id` bigint DEFAULT NULL COMMENT '关联组织ID(GLOBAL时为空)', + `priority` int DEFAULT '0' COMMENT '优先级(数值越大越优先)', + `status` varchar(16) DEFAULT 'ACTIVE' COMMENT 'ACTIVE/INACTIVE', + `effective_from` datetime DEFAULT NULL COMMENT '生效开始时间', + `effective_to` datetime DEFAULT NULL COMMENT '生效结束时间', + `delete_flag` varchar(8) DEFAULT 'UNDEL', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_scope` (`scope_type`,`scope_org_id`), + KEY `idx_status` (`status`,`delete_flag`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='分润规则表'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `profit_sharing_rule_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `profit_sharing_rule_item` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `rule_id` bigint NOT NULL COMMENT '关联规则', + `org_role` varchar(32) NOT NULL COMMENT '角色:PLATFORM/CHANNEL/AGENT/STORE', + `share_type` varchar(16) NOT NULL COMMENT 'RATIO-比例 / FIXED-固定金额', + `share_value` decimal(10,2) NOT NULL COMMENT '比例:百分比(如20表示20%);固定:分', + `sort_order` int DEFAULT '0' COMMENT '执行顺序', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_rule` (`rule_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='分润规则明细表'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `QRTZ_BLOB_TRIGGERS`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `QRTZ_BLOB_TRIGGERS` ( + `SCHED_NAME` varchar(120) NOT NULL, + `TRIGGER_NAME` varchar(200) NOT NULL, + `TRIGGER_GROUP` varchar(200) NOT NULL, + `BLOB_DATA` blob, + PRIMARY KEY (`SCHED_NAME`,`TRIGGER_NAME`,`TRIGGER_GROUP`), + KEY `SCHED_NAME` (`SCHED_NAME`,`TRIGGER_NAME`,`TRIGGER_GROUP`), + CONSTRAINT `qrtz_blob_triggers_ibfk_1` FOREIGN KEY (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`) REFERENCES `QRTZ_TRIGGERS` (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `QRTZ_CALENDARS`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `QRTZ_CALENDARS` ( + `SCHED_NAME` varchar(120) NOT NULL, + `CALENDAR_NAME` varchar(200) NOT NULL, + `CALENDAR` blob NOT NULL, + PRIMARY KEY (`SCHED_NAME`,`CALENDAR_NAME`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `QRTZ_CRON_TRIGGERS`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `QRTZ_CRON_TRIGGERS` ( + `SCHED_NAME` varchar(120) NOT NULL, + `TRIGGER_NAME` varchar(200) NOT NULL, + `TRIGGER_GROUP` varchar(200) NOT NULL, + `CRON_EXPRESSION` varchar(120) NOT NULL, + `TIME_ZONE_ID` varchar(80) DEFAULT NULL, + PRIMARY KEY (`SCHED_NAME`,`TRIGGER_NAME`,`TRIGGER_GROUP`), + CONSTRAINT `qrtz_cron_triggers_ibfk_1` FOREIGN KEY (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`) REFERENCES `QRTZ_TRIGGERS` (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `QRTZ_FIRED_TRIGGERS`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `QRTZ_FIRED_TRIGGERS` ( + `SCHED_NAME` varchar(120) NOT NULL, + `ENTRY_ID` varchar(95) NOT NULL, + `TRIGGER_NAME` varchar(200) NOT NULL, + `TRIGGER_GROUP` varchar(200) NOT NULL, + `INSTANCE_NAME` varchar(200) NOT NULL, + `FIRED_TIME` bigint NOT NULL, + `SCHED_TIME` bigint NOT NULL, + `PRIORITY` int NOT NULL, + `STATE` varchar(16) NOT NULL, + `JOB_NAME` varchar(200) DEFAULT NULL, + `JOB_GROUP` varchar(200) DEFAULT NULL, + `IS_NONCONCURRENT` varchar(1) DEFAULT NULL, + `REQUESTS_RECOVERY` varchar(1) DEFAULT NULL, + PRIMARY KEY (`SCHED_NAME`,`ENTRY_ID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `QRTZ_JOB_DETAILS`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `QRTZ_JOB_DETAILS` ( + `SCHED_NAME` varchar(120) NOT NULL, + `JOB_NAME` varchar(200) NOT NULL, + `JOB_GROUP` varchar(200) NOT NULL, + `DESCRIPTION` varchar(250) DEFAULT NULL, + `JOB_CLASS_NAME` varchar(250) NOT NULL, + `IS_DURABLE` varchar(1) NOT NULL, + `IS_NONCONCURRENT` varchar(1) NOT NULL, + `IS_UPDATE_DATA` varchar(1) NOT NULL, + `REQUESTS_RECOVERY` varchar(1) NOT NULL, + `JOB_DATA` blob, + PRIMARY KEY (`SCHED_NAME`,`JOB_NAME`,`JOB_GROUP`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `QRTZ_LOCKS`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `QRTZ_LOCKS` ( + `SCHED_NAME` varchar(120) NOT NULL, + `LOCK_NAME` varchar(40) NOT NULL, + PRIMARY KEY (`SCHED_NAME`,`LOCK_NAME`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `QRTZ_PAUSED_TRIGGER_GRPS`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `QRTZ_PAUSED_TRIGGER_GRPS` ( + `SCHED_NAME` varchar(120) NOT NULL, + `TRIGGER_GROUP` varchar(200) NOT NULL, + PRIMARY KEY (`SCHED_NAME`,`TRIGGER_GROUP`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `QRTZ_SCHEDULER_STATE`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `QRTZ_SCHEDULER_STATE` ( + `SCHED_NAME` varchar(120) NOT NULL, + `INSTANCE_NAME` varchar(200) NOT NULL, + `LAST_CHECKIN_TIME` bigint NOT NULL, + `CHECKIN_INTERVAL` bigint NOT NULL, + PRIMARY KEY (`SCHED_NAME`,`INSTANCE_NAME`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `QRTZ_SIMPLE_TRIGGERS`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `QRTZ_SIMPLE_TRIGGERS` ( + `SCHED_NAME` varchar(120) NOT NULL, + `TRIGGER_NAME` varchar(200) NOT NULL, + `TRIGGER_GROUP` varchar(200) NOT NULL, + `REPEAT_COUNT` bigint NOT NULL, + `REPEAT_INTERVAL` bigint NOT NULL, + `TIMES_TRIGGERED` bigint NOT NULL, + PRIMARY KEY (`SCHED_NAME`,`TRIGGER_NAME`,`TRIGGER_GROUP`), + CONSTRAINT `qrtz_simple_triggers_ibfk_1` FOREIGN KEY (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`) REFERENCES `QRTZ_TRIGGERS` (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `QRTZ_SIMPROP_TRIGGERS`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `QRTZ_SIMPROP_TRIGGERS` ( + `SCHED_NAME` varchar(120) NOT NULL, + `TRIGGER_NAME` varchar(200) NOT NULL, + `TRIGGER_GROUP` varchar(200) NOT NULL, + `STR_PROP_1` varchar(512) DEFAULT NULL, + `STR_PROP_2` varchar(512) DEFAULT NULL, + `STR_PROP_3` varchar(512) DEFAULT NULL, + `INT_PROP_1` int DEFAULT NULL, + `INT_PROP_2` int DEFAULT NULL, + `LONG_PROP_1` bigint DEFAULT NULL, + `LONG_PROP_2` bigint DEFAULT NULL, + `DEC_PROP_1` decimal(13,4) DEFAULT NULL, + `DEC_PROP_2` decimal(13,4) DEFAULT NULL, + `BOOL_PROP_1` varchar(1) DEFAULT NULL, + `BOOL_PROP_2` varchar(1) DEFAULT NULL, + PRIMARY KEY (`SCHED_NAME`,`TRIGGER_NAME`,`TRIGGER_GROUP`), + CONSTRAINT `qrtz_simprop_triggers_ibfk_1` FOREIGN KEY (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`) REFERENCES `QRTZ_TRIGGERS` (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `QRTZ_TRIGGERS`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `QRTZ_TRIGGERS` ( + `SCHED_NAME` varchar(120) NOT NULL, + `TRIGGER_NAME` varchar(200) NOT NULL, + `TRIGGER_GROUP` varchar(200) NOT NULL, + `JOB_NAME` varchar(200) NOT NULL, + `JOB_GROUP` varchar(200) NOT NULL, + `DESCRIPTION` varchar(250) DEFAULT NULL, + `NEXT_FIRE_TIME` bigint DEFAULT NULL, + `PREV_FIRE_TIME` bigint DEFAULT NULL, + `PRIORITY` int DEFAULT NULL, + `TRIGGER_STATE` varchar(16) NOT NULL, + `TRIGGER_TYPE` varchar(8) NOT NULL, + `START_TIME` bigint NOT NULL, + `END_TIME` bigint DEFAULT NULL, + `CALENDAR_NAME` varchar(200) DEFAULT NULL, + `MISFIRE_INSTR` smallint DEFAULT NULL, + `JOB_DATA` blob, + PRIMARY KEY (`SCHED_NAME`,`TRIGGER_NAME`,`TRIGGER_GROUP`), + KEY `SCHED_NAME` (`SCHED_NAME`,`JOB_NAME`,`JOB_GROUP`), + CONSTRAINT `qrtz_triggers_ibfk_1` FOREIGN KEY (`SCHED_NAME`, `JOB_NAME`, `JOB_GROUP`) REFERENCES `QRTZ_JOB_DETAILS` (`SCHED_NAME`, `JOB_NAME`, `JOB_GROUP`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `schedule_job`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `schedule_job` ( + `job_id` bigint NOT NULL AUTO_INCREMENT COMMENT '任务id', + `bean_name` varchar(200) DEFAULT NULL COMMENT 'spring bean名称', + `params` varchar(2000) DEFAULT NULL COMMENT '参数', + `cron_expression` varchar(100) DEFAULT NULL COMMENT 'cron表达式', + `status` tinyint DEFAULT NULL COMMENT '任务状态 0:正常 1:暂停', + `remark` varchar(255) DEFAULT NULL COMMENT '备注', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`job_id`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='定时任务'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `schedule_job_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `schedule_job_log` ( + `log_id` bigint NOT NULL AUTO_INCREMENT COMMENT '任务日志id', + `job_id` bigint NOT NULL COMMENT '任务id', + `bean_name` varchar(200) DEFAULT NULL COMMENT 'spring bean名称', + `params` varchar(2000) DEFAULT NULL COMMENT '参数', + `status` tinyint NOT NULL COMMENT '任务状态 0:成功 1:失败', + `error` varchar(2000) DEFAULT NULL COMMENT '失败信息', + `times` int NOT NULL COMMENT '耗时(单位:毫秒)', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`log_id`), + KEY `job_id` (`job_id`) +) ENGINE=InnoDB AUTO_INCREMENT=2012 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='定时任务日志'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `store_profile`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `store_profile` ( + `org_id` bigint NOT NULL COMMENT '组织ID,关联 sys_org.id(type=STORE)', + `store_name` varchar(100) DEFAULT NULL, + `legal_person` varchar(50) DEFAULT NULL, + `license_no` varchar(100) DEFAULT NULL, + `license_image_url` varchar(500) DEFAULT NULL, + `country` varchar(50) DEFAULT '中国', + `region` varchar(200) DEFAULT NULL, + `address` varchar(500) DEFAULT NULL, + `phone` varchar(50) DEFAULT NULL, + `remark` varchar(500) DEFAULT NULL, + `status` varchar(20) DEFAULT 'ACTIVE', + `delete_flag` varchar(10) DEFAULT 'UNDEL', + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='门店扩展信息'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `sys_captcha`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_captcha` ( + `uuid` char(36) NOT NULL COMMENT 'uuid', + `code` varchar(6) NOT NULL COMMENT '验证码', + `expire_time` datetime DEFAULT NULL COMMENT '过期时间', + PRIMARY KEY (`uuid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统验证码'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `sys_config`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `param_key` varchar(50) DEFAULT NULL COMMENT 'key', + `param_value` varchar(2000) DEFAULT NULL COMMENT 'value', + `status` tinyint DEFAULT '1' COMMENT '状态 0:隐藏 1:显示', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`id`), + UNIQUE KEY `param_key` (`param_key`) +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统配置信息表'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `sys_menu`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_menu` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `parent_id` bigint DEFAULT '0' COMMENT '父菜单ID', + `name` varchar(50) DEFAULT NULL COMMENT '菜单名称', + `url` varchar(200) DEFAULT NULL COMMENT '菜单URL', + `perms` varchar(500) DEFAULT NULL COMMENT '授权', + `type` int DEFAULT NULL COMMENT '类型 0:目录 1:菜单 2:按钮', + `icon` varchar(50) DEFAULT NULL COMMENT '菜单图标', + `show` varchar(10) DEFAULT '10' COMMENT '是否显示 10:显示 20:隐藏', + `order_num` int DEFAULT '0' COMMENT '排序', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=122 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='菜单管理'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `sys_org`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_org` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `parent_id` bigint DEFAULT NULL COMMENT '父组织ID;type=PLATFORM时为NULL', + `mpath` varchar(500) DEFAULT NULL COMMENT '子树路径(materialized-path),如 1.2.3.', + `name` varchar(100) NOT NULL COMMENT '组织名称', + `order_no` int DEFAULT '0' COMMENT '同级排序', + `type` varchar(20) NOT NULL COMMENT '类型 PLATFORM/CHANNEL/SERVICE_PROVIDER/STORE', + `code` varchar(64) DEFAULT NULL COMMENT '组织编码,全局唯一', + `can_hold_inventory` tinyint(1) DEFAULT '0' COMMENT '是否允许持仓', + `activation_prefix` varchar(10) DEFAULT NULL COMMENT '权益激活码前缀;type=CHANNEL时必填且全局唯一', + `brand_logo_url` varchar(500) DEFAULT NULL COMMENT '渠道品牌Logo', + `development_staff` varchar(100) DEFAULT NULL COMMENT '拓展/开发人员(仅SERVICE_PROVIDER)', + `contact_phone` varchar(50) DEFAULT NULL COMMENT '联系电话(仅SERVICE_PROVIDER)', + `service_partner` varchar(255) DEFAULT NULL COMMENT '服务合伙人说明(仅SERVICE_PROVIDER)', + `wechat_qr_image_url` varchar(500) DEFAULT NULL COMMENT '企业微信二维码图片URL(仅SERVICE_PROVIDER)', + `status` varchar(20) DEFAULT 'ACTIVE' COMMENT '状态 ACTIVE/DISABLED', + `create_by` bigint DEFAULT NULL, + `update_by` bigint DEFAULT NULL, + `delete_flag` varchar(10) DEFAULT 'UNDEL', + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_parent_id` (`parent_id`), + KEY `idx_type` (`type`), + KEY `idx_mpath` (`mpath`(255)) +) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='组织'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `sys_role`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_role` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `role_name` varchar(100) DEFAULT NULL COMMENT '角色名称', + `remark` varchar(100) DEFAULT NULL COMMENT '备注', + `status` varchar(10) DEFAULT '1' COMMENT '状态', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `sys_role_menu`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_role_menu` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `role_id` bigint DEFAULT NULL COMMENT '角色ID', + `menu_id` bigint DEFAULT NULL COMMENT '菜单ID', + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=45 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色与菜单对应关系'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `sys_user`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_user` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `username` varchar(50) NOT NULL COMMENT '用户名', + `password` varchar(100) DEFAULT NULL COMMENT '密码', + `salt` varchar(20) DEFAULT NULL COMMENT '盐', + `avatar` varchar(255) DEFAULT NULL COMMENT '头像', + `email` varchar(100) DEFAULT NULL COMMENT '邮箱', + `mobile` varchar(100) DEFAULT NULL COMMENT '手机号', + `remark` varchar(200) DEFAULT NULL COMMENT '备注', + `status` tinyint DEFAULT '1' COMMENT '状态 0:禁用 1:正常', + `org_id` bigint DEFAULT NULL COMMENT '组织ID(null=平台超管)', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统用户'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `sys_user_role`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_user_role` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint DEFAULT NULL COMMENT '用户ID', + `role_id` bigint DEFAULT NULL COMMENT '角色ID', + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户与角色对应关系'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `sys_user_token`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_user_token` ( + `user_id` bigint NOT NULL, + `token` varchar(100) NOT NULL COMMENT 'access token', + `expire_time` datetime DEFAULT NULL COMMENT 'access过期时间', + `refresh_token` varchar(100) DEFAULT NULL COMMENT 'refresh token', + `refresh_expire_time` datetime DEFAULT NULL COMMENT 'refresh过期时间', + `previous_refresh_token` varchar(100) DEFAULT NULL COMMENT '上一个refresh token(防复用)', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`user_id`), + UNIQUE KEY `uk_token` (`token`), + UNIQUE KEY `uk_refresh_token` (`refresh_token`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统用户Token'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `wechat_pay_config`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `wechat_pay_config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `partner_id` bigint DEFAULT NULL COMMENT '合作伙伴ID(空=全局默认)', + `app_id` varchar(64) DEFAULT NULL COMMENT '微信应用ID', + `mch_id` varchar(32) DEFAULT NULL COMMENT '商户号', + `api_v3_key` varchar(64) DEFAULT NULL COMMENT 'APIv3密钥', + `cert_serial_no` varchar(128) DEFAULT NULL COMMENT '证书序列号', + `private_key_path` varchar(500) DEFAULT NULL COMMENT '私钥文件路径', + `notify_url` varchar(500) DEFAULT NULL COMMENT '回调通知URL', + `status` varchar(20) DEFAULT 'ACTIVE' COMMENT '状态 ACTIVE/DISABLED', + `remark` varchar(255) DEFAULT NULL, + `delete_flag` varchar(10) DEFAULT 'UNDEL', + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_partner_id` (`partner_id`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='微信支付配置'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + diff --git a/db/init-sys-data.sql b/db/init-sys-data.sql new file mode 100644 index 0000000..77034a7 --- /dev/null +++ b/db/init-sys-data.sql @@ -0,0 +1,63 @@ +-- 系统初始化数据(菜单 + 角色 + 超管用户) +-- 在服务器执行:mysql -ujstopenapi -p'MAe8pALGWjE6jCwi' jstopenapi < db/init-sys-data.sql + +-- 菜单 +INSERT INTO `sys_menu` (`id`, `parent_id`, `name`, `url`, `perms`, `type`, `icon`, `show`, `order_num`, `created_at`, `updated_at`) VALUES +(1,0,'系统管理',NULL,NULL,0,'system','10',0,NULL,NULL), +(2,1,'管理员列表','sys/user',NULL,1,'admin','10',1,NULL,NULL), +(3,1,'角色管理','sys/role',NULL,1,'role','10',2,NULL,NULL), +(4,1,'菜单管理','sys/menu',NULL,1,'menu','10',3,NULL,NULL), +(15,2,'查看',NULL,'sys:user:list,sys:user:info',2,NULL,'10',0,NULL,NULL), +(16,2,'新增',NULL,'sys:user:save,sys:role:select',2,NULL,'10',0,NULL,NULL), +(17,2,'修改',NULL,'sys:user:update,sys:role:select',2,NULL,'10',0,NULL,NULL), +(18,2,'删除',NULL,'sys:user:delete',2,NULL,'10',0,NULL,NULL), +(19,3,'查看',NULL,'sys:role:list,sys:role:info',2,NULL,'10',0,NULL,NULL), +(20,3,'新增',NULL,'sys:role:save,sys:menu:list',2,NULL,'10',0,NULL,NULL), +(21,3,'修改',NULL,'sys:role:update,sys:menu:list',2,NULL,'10',0,NULL,NULL), +(22,3,'删除',NULL,'sys:role:delete',2,NULL,'10',0,NULL,NULL), +(23,4,'查看',NULL,'sys:menu:list,sys:menu:info',2,NULL,'10',0,NULL,NULL), +(24,4,'新增',NULL,'sys:menu:save,sys:menu:select',2,NULL,'10',0,NULL,NULL), +(25,4,'修改',NULL,'sys:menu:update,sys:menu:select',2,NULL,'10',0,NULL,NULL), +(26,4,'删除',NULL,'sys:menu:delete',2,NULL,'10',0,NULL,NULL), +(100,0,'业务管理',NULL,NULL,0,'el-icon-menu','10',1,'2026-07-12 15:57:45','2026-07-22 12:56:21'), +(101,100,'渠道管理','org/org',NULL,1,'el-icon-share','10',1,'2026-07-12 15:57:45','2026-07-12 15:57:45'), +(102,100,'门店管理','org/shop',NULL,1,'el-icon-shop','10',2,'2026-07-12 15:57:45','2026-07-12 15:57:45'), +(103,100,'设备管理','manage/device',NULL,1,'el-icon-monitor','10',3,'2026-07-12 15:57:45','2026-07-12 15:57:45'), +(104,100,'支付配置','manage/pay-config',NULL,1,'el-icon-wallet','10',4,'2026-07-12 15:57:45','2026-07-12 15:57:45'), +(105,100,'定价策略','manage/price',NULL,1,'el-icon-price-tag','10',5,'2026-07-12 15:57:45','2026-07-12 15:57:45'), +(106,100,'统计概览','manage/stats-overview',NULL,1,'el-icon-data-line','10',6,'2026-07-12 15:57:45','2026-07-12 15:57:45'), +(109,100,'天卡池管理','manage/card-pool',NULL,1,'el-icon-box','10',9,'2026-07-13 20:22:01','2026-07-13 20:22:01'), +(110,100,'天卡订单','manage/card-order',NULL,1,'el-icon-document','10',10,'2026-07-13 20:22:01','2026-07-13 20:22:01'), +(111,100,'分账接收方','manage/profit-receiver',NULL,1,'el-icon-connection','10',11,'2026-07-14 10:03:59','2026-07-14 10:03:59'), +(112,100,'退款管理','manage/refund',NULL,1,'el-icon-refresh-left','10',12,'2026-07-14 10:03:59','2026-07-14 10:03:59'), +(113,100,'天卡激活码','manage/activation-code','',1,'Ticket','10',13,'2026-07-14 15:59:35','2026-07-14 15:59:35'), +(114,100,'分润规则','manage/profit-rule','',1,'Coin','10',14,'2026-07-14 15:59:35','2026-07-14 15:59:35'), +(115,100,'分账记录','manage/profit-sharing','',1,'Wallet','10',15,'2026-07-14 15:59:35','2026-07-14 15:59:35'), +(116,0,'媒资管理',NULL,'',0,'Film','10',2,'2026-07-21 18:00:28','2026-07-21 18:00:28'), +(117,116,'橘视厅媒资','jetsen-media/list','',1,'Film','10',1,'2026-07-21 18:00:28','2026-07-21 18:00:28'), +(118,121,'合作伙伴管理','manage/partner','',1,'Connection','10',16,'2026-07-22 12:56:21','2026-07-22 13:43:39'), +(119,121,'内容包配置','manage/content-package','',1,'Files','10',17,'2026-07-22 12:56:21','2026-07-22 13:43:39'), +(120,121,'天卡库存','manage/partner-card','',1,'ShoppingCart','10',18,'2026-07-22 12:56:21','2026-07-22 14:59:52'), +(121,0,'第三方接口管理',NULL,'',0,'Setting','10',19,'2026-07-22 13:43:39','2026-07-22 13:43:39'); + +-- 角色 +INSERT INTO `sys_role` (`id`, `role_name`, `remark`, `status`, `created_at`, `updated_at`) VALUES +(1,'超级管理员','平台超级管理员','1','2026-07-12 15:25:13',NULL), +(2,'渠道','渠道角色','1','2026-07-12 15:25:13',NULL), +(3,'代理商','代理商角色','1','2026-07-12 15:25:13',NULL), +(4,'门店','门店角色','1','2026-07-12 15:25:13',NULL); + +-- 超管用户(admin/admin,org_id=NULL 即超管) +INSERT INTO `sys_user` (`id`, `username`, `password`, `salt`, `email`, `mobile`, `status`, `org_id`, `created_at`) VALUES +(1,'admin','9ec9750e709431dad22365cabc5c625482e574c74adaebba7dd02f1129e4ce1d','YzcmCZNvbXocrsz9dm8e','admin@as.io','13612345678',1,NULL,'2026-07-12 15:25:13'); + +-- 用户角色关联 +INSERT INTO `sys_user_role` (`id`, `user_id`, `role_id`) VALUES +(1,1,1); + +-- 角色菜单关联(超管通过 org_id=NULL 已绕过,此数据仅备份) +INSERT INTO `sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES +(1,1,1),(2,1,2),(3,1,3),(4,1,4),(5,1,15),(6,1,16),(7,1,17),(8,1,18), +(9,1,19),(10,1,20),(11,1,21),(12,1,22),(13,1,23),(14,1,24),(15,1,25),(16,1,26), +(32,1,100),(33,1,101),(34,1,102),(35,1,103),(36,1,104),(37,1,105),(38,1,106), +(41,1,109),(42,1,110),(43,1,111),(44,1,112); diff --git a/db/mysql.sql b/db/mysql.sql new file mode 100644 index 0000000..d8433b9 --- /dev/null +++ b/db/mysql.sql @@ -0,0 +1,146 @@ +-- ============================================================ +-- 天卡 OpenAPI 数据库 DDL 脚本 +-- 数据库: MySQL 8.0+ +-- 字符集: utf8mb4 +-- 说明: 字段命名与 Entity/Service 代码严格对齐 +-- 通用字段: created_at/updated_at/delete_flag +-- 状态值: 大写英文枚举字符串 +-- ============================================================ + +-- ----------------------------------------------------------- +-- 1. 合作伙伴表 +-- Entity: OpenApiPartnerEntity +-- 对应 Service 列名: partner_id, app_key, name, status, delete_flag, created_at +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `open_api_partner` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `partner_id` VARCHAR(64) NOT NULL COMMENT '合作伙伴ID', + `app_key` VARCHAR(64) NOT NULL COMMENT '应用Key', + `app_secret` VARCHAR(128) NOT NULL COMMENT '应用密钥', + `name` VARCHAR(100) DEFAULT NULL COMMENT '合作伙伴名称', + `status` VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/DISABLED', + `api_permissions` TEXT DEFAULT NULL COMMENT 'API权限(JSON)', + `rate_limit` INT NOT NULL DEFAULT 100 COMMENT '速率限制(每秒请求数)', + `delete_flag` VARCHAR(10) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_app_key` (`app_key`), + UNIQUE KEY `uk_partner_id` (`partner_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开放API合作伙伴'; + + +-- ----------------------------------------------------------- +-- 2. 天卡池表 +-- Entity: DayCardPoolEntity +-- 对应 Service 列名: card_code, partner_id, status, order_no, device_id, delete_flag, created_at, updated_at +-- 对应 Mapper XML: selectOneForUpdateSkipLocked (status, delete_flag, partner_id) +-- 状态枚举: UNUSED/RESERVED/USED +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `day_card_pool` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `card_code` VARCHAR(128) NOT NULL COMMENT '卡密(导入的激活码)', + `third_party_ref` VARCHAR(128) DEFAULT NULL COMMENT '第三方引用标识', + `third_card_no` VARCHAR(128) DEFAULT NULL COMMENT '传第三方的唯一卡号', + `card_type` VARCHAR(20) NOT NULL DEFAULT 'DAY' COMMENT '卡类型:DAY', + `partner_id` BIGINT DEFAULT NULL COMMENT '所属合作伙伴ID', + `status` VARCHAR(20) NOT NULL DEFAULT 'UNUSED' COMMENT '状态:UNUSED/RESERVED/USED', + `order_no` VARCHAR(32) DEFAULT NULL COMMENT '关联订单号', + `device_id` BIGINT DEFAULT NULL COMMENT '关联设备ID', + `import_batch_id` BIGINT DEFAULT NULL COMMENT '导入批次ID', + `expire_at` DATETIME DEFAULT NULL COMMENT '卡过期时间', + `used_at` DATETIME DEFAULT NULL COMMENT '使用时间', + `delete_flag` VARCHAR(10) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_card_code` (`card_code`), + KEY `idx_partner_status` (`partner_id`, `status`), + KEY `idx_order_no` (`order_no`), + KEY `idx_device_id` (`device_id`), + KEY `idx_import_batch_id` (`import_batch_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天卡池'; + + +-- ----------------------------------------------------------- +-- 3. 天卡订单表 +-- Entity: DayCardOrderEntity +-- 对应 Service 列名: order_no, partner_id, device_id, status, amount, pay_no, pay_amount, pay_time, expire_time, cancel_time, delete_flag, created_at, updated_at +-- 状态枚举: CREATED/PAYING/PAID/PAY_FAILED/CANCELLED +-- MediaServiceImpl SQL: device_id, status, expire_time +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `day_card_order` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `order_no` VARCHAR(32) NOT NULL COMMENT '订单号', + `partner_id` BIGINT DEFAULT NULL COMMENT '合作伙伴ID', + `device_id` BIGINT DEFAULT NULL COMMENT '设备ID', + `card_no` VARCHAR(128) DEFAULT NULL COMMENT '卡号(支付后写入,关联day_card_pool.card_code)', + `status` VARCHAR(20) NOT NULL DEFAULT 'CREATED' COMMENT '订单状态:CREATED/PAYING/PAID/PAY_FAILED/CANCELLED', + `amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '订单金额(元)', + `pay_no` VARCHAR(64) DEFAULT NULL COMMENT '支付流水号', + `pay_amount` DECIMAL(10,2) DEFAULT NULL COMMENT '实际支付金额(元)', + `pay_time` DATETIME DEFAULT NULL COMMENT '支付时间', + `expire_time` DATETIME DEFAULT NULL COMMENT '订单过期时间(15分钟)', + `cancel_time` DATETIME DEFAULT NULL COMMENT '取消时间', + `delete_flag` VARCHAR(10) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_order_no` (`order_no`), + KEY `idx_device_status` (`device_id`, `status`), + KEY `idx_status_expire` (`status`, `expire_time`), + KEY `idx_partner_status` (`partner_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天卡订单表'; + + +-- ----------------------------------------------------------- +-- 4. 天卡导入批次表 +-- Entity: DayCardImportBatchEntity +-- 状态枚举: PROCESSING/SUCCEEDED/PARTIAL_FAILED/FAILED +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `day_card_import_batch` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `batch_no` VARCHAR(64) NOT NULL COMMENT '批次号', + `partner_id` BIGINT DEFAULT NULL COMMENT '合作伙伴ID', + `status` VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT '状态:PROCESSING/SUCCEEDED/PARTIAL_FAILED/FAILED', + `total_count` INT NOT NULL DEFAULT 0 COMMENT '总数量', + `success_count` INT NOT NULL DEFAULT 0 COMMENT '成功数量', + `fail_count` INT NOT NULL DEFAULT 0 COMMENT '失败数量', + `result_report_url` VARCHAR(500) DEFAULT NULL COMMENT '结果报告URL', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人', + `delete_flag` VARCHAR(10) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_batch_no` (`batch_no`), + KEY `idx_partner_id` (`partner_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天卡导入批次'; + + +-- ----------------------------------------------------------- +-- 5. 天卡设备激活码表 +-- Entity: DayCardActivationCodeEntity +-- 用途: 管理后台按门店生成激活码,App 端 init 时输入激活码完成设备注册绑定 +-- 状态枚举: ACTIVE/DISABLED +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `day_card_activation_code` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `code` VARCHAR(32) NOT NULL COMMENT '激活码(唯一)', + `channel_org_id` BIGINT NOT NULL COMMENT '渠道组织ID', + `store_org_id` BIGINT NOT NULL COMMENT '门店组织ID', + `status` VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/DISABLED', + `max_device_count` INT NOT NULL DEFAULT 0 COMMENT '最大可激活设备数(0=不限)', + `activated_count` INT NOT NULL DEFAULT 0 COMMENT '已激活设备数', + `remark` VARCHAR(255) DEFAULT NULL COMMENT '备注', + `delete_flag` VARCHAR(10) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_code` (`code`), + KEY `idx_store_org_id` (`store_org_id`), + KEY `idx_channel_org_id` (`channel_org_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天卡设备激活码'; diff --git a/db/openapi_media.sql b/db/openapi_media.sql new file mode 100644 index 0000000..537545e --- /dev/null +++ b/db/openapi_media.sql @@ -0,0 +1,31 @@ +-- 内容包授权配置(一个partner一条记录,三级fallback:content_codes > catalog_ids > 全量) +CREATE TABLE IF NOT EXISTS open_api_content_package ( + id BIGINT NOT NULL AUTO_INCREMENT, + partner_id VARCHAR(32) NOT NULL COMMENT '合作伙伴ID', + name VARCHAR(128) DEFAULT NULL COMMENT '内容包名称', + catalog_ids VARCHAR(2000) DEFAULT NULL COMMENT '授权栏目ID列表(逗号分隔,二级栏目id如472)', + content_codes TEXT DEFAULT NULL COMMENT '授权内容code列表(逗号分隔),非空时优先按内容过滤', + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/DISABLED', + delete_flag VARCHAR(8) NOT NULL DEFAULT 'UNDEL', + created_at DATETIME DEFAULT NULL, + updated_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_partner_id (partner_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开放API内容包授权'; + +-- 第三方调用日志(可溯源) +CREATE TABLE IF NOT EXISTS open_api_call_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + partner_id VARCHAR(32) DEFAULT NULL COMMENT '合作伙伴ID', + app_key VARCHAR(32) DEFAULT NULL, + api VARCHAR(256) DEFAULT NULL COMMENT '请求路径', + http_method VARCHAR(16) DEFAULT NULL, + params VARCHAR(2000) DEFAULT NULL COMMENT '请求参数', + result_code INT DEFAULT NULL COMMENT '返回业务code', + ip VARCHAR(64) DEFAULT NULL, + cost_ms INT DEFAULT NULL COMMENT '耗时毫秒', + created_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_partner_id (partner_id), + KEY idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开放API调用日志'; diff --git a/db/openapi_partner_card_pool.sql b/db/openapi_partner_card_pool.sql new file mode 100644 index 0000000..3f0f541 --- /dev/null +++ b/db/openapi_partner_card_pool.sql @@ -0,0 +1,20 @@ +-- 合作伙伴天卡池(代销模式:第三方采购的天卡存这里,消耗时绑定设备) +CREATE TABLE IF NOT EXISTS open_api_partner_card_pool ( + id BIGINT NOT NULL AUTO_INCREMENT, + partner_id VARCHAR(32) NOT NULL COMMENT '合作伙伴ID(open_api_partner.partner_id)', + pool_id BIGINT NOT NULL COMMENT 'day_card_pool.id 天卡池主键', + card_code VARCHAR(64) DEFAULT NULL COMMENT '天卡卡密', + third_card_no VARCHAR(64) DEFAULT NULL COMMENT '橘视厅第三方卡号', + status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE可用/CONSUMED已消耗/DISABLED已禁用', + device_id BIGINT DEFAULT NULL COMMENT '消耗后绑定的设备ID', + channel_org_id BIGINT DEFAULT NULL COMMENT '渠道组织ID(sys_org.org_id)', + purchase_batch VARCHAR(32) DEFAULT NULL COMMENT '采购批次号', + consumed_at DATETIME DEFAULT NULL COMMENT '消耗时间', + delete_flag VARCHAR(8) NOT NULL DEFAULT 'UNDEL', + created_at DATETIME DEFAULT NULL, + updated_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_partner_id (partner_id), + KEY idx_partner_status (partner_id, status), + KEY idx_pool_id (pool_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='合作伙伴天卡池(代销模式)'; diff --git a/db/price-config-3layer.sql b/db/price-config-3layer.sql new file mode 100644 index 0000000..dc36dcd --- /dev/null +++ b/db/price-config-3layer.sql @@ -0,0 +1,13 @@ +-- =========================================================== +-- price-config-3layer.sql +-- 定价策略升级为 3 层维度:门店配置价 > 渠道配置价 > 平台默认价兜底 +-- 同时取消时长约束(天卡固定 24 小时,不再按配置存时长) +-- =========================================================== + +-- 1. 新增门店组织ID列(空=渠道级或平台级) +ALTER TABLE `day_card_price_config` + ADD COLUMN `store_org_id` BIGINT DEFAULT NULL COMMENT '门店组织ID(空=渠道级/平台级)' AFTER `channel_org_id`, + ADD INDEX `idx_store_org_id` (`store_org_id`); + +-- 2. 取消时长约束,删除 duration_hours 列 +ALTER TABLE `day_card_price_config` DROP COLUMN `duration_hours`; diff --git a/db/restore_test_card_and_jst_config.sql b/db/restore_test_card_and_jst_config.sql new file mode 100644 index 0000000..aa13fee --- /dev/null +++ b/db/restore_test_card_and_jst_config.sql @@ -0,0 +1,61 @@ +-- ============================================================ +-- 1. 还原测试卡(在云服务器数据库 jstopenapi 上执行) +-- ============================================================ + +-- 1.1 重置平台天卡池:USED -> RESERVED,清除激活状态和设备绑定 +UPDATE day_card_pool +SET status = 'RESERVED', + activate_status = NULL, + activate_error = NULL, + device_id = NULL, + used_at = NULL, + expire_at = NULL, + activated_at = NULL, + updated_at = NOW() +WHERE third_card_no = 'DKMMqjJKgpdwUahip5l4hGzjS' + AND status = 'USED'; + +-- 1.2 重置partner天卡池:CONSUMED -> AVAILABLE,清除设备绑定 +UPDATE open_api_partner_card_pool +SET status = 'AVAILABLE', + device_id = NULL, + channel_org_id = NULL, + consumed_at = NULL, + updated_at = NOW() +WHERE third_card_no = 'DKMMqjJKgpdwUahip5l4hGzjS' + AND status = 'CONSUMED'; + +-- 1.3 验证还原结果 +SELECT 'day_card_pool' AS tbl, id, card_code, third_card_no, status, activate_status, device_id +FROM day_card_pool WHERE third_card_no = 'DKMMqjJKgpdwUahip5l4hGzjS'; + +SELECT 'partner_card_pool' AS tbl, id, partner_id, pool_id, card_code, third_card_no, status, device_id +FROM open_api_partner_card_pool WHERE third_card_no = 'DKMMqjJKgpdwUahip5l4hGzjS'; + +-- 1.4 清除Redis缓存(在服务器上执行,防止Token缓存和激活锁干扰重试) +-- docker exec daycard-redis redis-cli DEL "api:thirdparty:activate:DKMMqjJKgpdwUahip5l4hGzjS" +-- docker exec daycard-redis redis-cli KEYS "api:thirdparty:activate:lock:*" | xargs -I{} docker exec daycard-redis redis-cli DEL {} +-- docker exec daycard-redis redis-cli KEYS "daycard:partner:consume:*" | xargs -I{} docker exec daycard-redis redis-cli DEL {} + + +-- ============================================================ +-- 2. 导入橘视厅配置(在云服务器数据库 jstopenapi 上执行) +-- ============================================================ + +-- sys_config 表结构: id(PK), param_key(UNIQUE), param_value(varchar2000), status(tinyint default 1), remark(varchar500) +-- param_key 有 UNIQUE 约束,ON DUPLICATE KEY UPDATE 自动处理已存在情况 + +INSERT INTO sys_config (param_key, param_value, status, remark) +VALUES ( + 'JST_PLATFORM_CONFIG', + '{"jushitingBaseUrl":"https://api.future.jetsentv.com","vipQueryBaseUrl":"https://api.future.jetsentv.com","jushitingTenant":"huashitv","jushitingAccesser":"JSTASJD","jushitingBusinessLine":"4100450011381755998420012","jushitingServiceComboCode":"1100121010200494755850172","jushitingLoginRsaPublicKey":"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtlfaNsJwQ8wuVdITMLJPrXMeD9idJ0aTziOgkZZhigwzhyPNDAqw+nCKeJGGoTJyhbjPXKtnyI8eNpj+xhCCMXUD3BxOVMFWBhFxzIl83IQJG43mPRXwsJrI4oCYDaKv5aeZZBqF0OLPglXU+q19/28ltONvxcKp8YxXagmixzz1SDgLITM3s3ExqiaScK1dcpPtXbCpI4GXPP9l0ekkCBAo/d1oQqbxHvdE5ufAhEZ042k5tVHUgX9cUoedA9F2JcPhc+zUNCJUAQLvfVXxqNdhwxA4TY+64OX2iSje/cTqxp+4RcrX4rbUIYZitVQCRexZjKz7Yd/KXp8CKCho6wIDAQAB","jushitingActivateSignKey":"hhDEHY1qC4r","vipQuerySignKey":"c25svtwbTyCE"}', + 1, + '聚视厅/橘视厅第三方平台配置(JSON)' +) +ON DUPLICATE KEY UPDATE + param_value = VALUES(param_value), + status = 1, + remark = VALUES(remark); + +-- 验证 +SELECT param_key, param_value, remark FROM sys_config WHERE param_key = 'JST_PLATFORM_CONFIG'; diff --git a/db/schedule-timeout-task.sql b/db/schedule-timeout-task.sql new file mode 100644 index 0000000..c321c58 --- /dev/null +++ b/db/schedule-timeout-task.sql @@ -0,0 +1,21 @@ +-- =========================================================== +-- schedule-timeout-task.sql +-- 修复:天卡订单超时取消任务未被调度,导致 RESERVED 卡永不释放 +-- 1) 注册 dayCardOrderTimeoutTask 到 schedule_job(每 2 分钟) +-- 2) 清理当前卡死的 RESERVED 卡与对应过期 PENDING 订单 +-- 注:新增调度任务需重启后端才会被 Quartz 加载生效 +-- =========================================================== + +-- 1. 注册超时取消任务(status=0 正常运行) +INSERT INTO schedule_job (bean_name, params, cron_expression, status, remark, create_time) +VALUES ('dayCardOrderTimeoutTask', '', '0 */2 * * * ?', 0, '天卡订单超时取消-释放预占卡', NOW()); + +-- 2. 释放卡死的 RESERVED 卡 -> UNUSED +UPDATE day_card_pool +SET status = 'UNUSED', order_no = NULL, device_id = NULL, updated_at = NOW() +WHERE status = 'RESERVED' AND delete_flag = 'UNDEL'; + +-- 3. 取消过期未支付订单 PENDING -> CANCELLED +UPDATE day_card_order +SET status = 'CANCELLED', cancel_time = NOW(), updated_at = NOW() +WHERE status = 'PENDING' AND delete_flag = 'UNDEL' AND expire_time < NOW(); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..62c3cef --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +version: "3.8" + +services: + daycard-openapi: + build: . + image: daycard-openapi:latest + container_name: daycard-openapi + ports: + - "8084:8084" + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + - SPRING_PROFILES_ACTIVE=prod + - SERVER_PORT=8084 + - TZ=Asia/Shanghai + - DB_URL=jdbc:mysql://host.docker.internal:3306/jstopenapi?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai + - DB_USERNAME=jstopenapi + - DB_PASSWORD=MAe8pALGWjE6jCwi + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_OPEN=true + - JWT_SECRET=2F064BA10037477FBB3A1BF9D508AE96 + - JWT_EXPIRE=604800 + - API_SIGN_SECRET=5F2A2DD8BFBA4DA1B87EFBB405671B3D + - DAYCARD_ORDER_TIMEOUT=15 + - DAYCARD_VALID_HOURS=24 + - WECHAT_PAY_APP_ID= + - WECHAT_PAY_MCH_ID= + - WECHAT_PAY_API_V3_KEY= + - WECHAT_PAY_CERT_SERIAL_NO= + - WECHAT_PAY_PRIVATE_KEY_PATH= + - WECHAT_PAY_NOTIFY_URL= + depends_on: + - redis + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: daycard-redis + ports: + - "6379:6379" + restart: unless-stopped diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..e3b881a --- /dev/null +++ b/pom.xml @@ -0,0 +1,270 @@ + + + 4.0.0 + as + daycard-openapi + 1.0.0 + jar + asDaycardOpenAPI + + + org.springframework.boot + spring-boot-starter-parent + 2.6.6 + + + + UTF-8 + UTF-8 + 1.8 + 3.3.1 + 8.0.28 + 1.1.13 + 2.3.0 + 2.6 + 1.2.2 + 2.5 + 1.10 + 1.10 + 1.9.0 + 0.7.0 + 2.7.0 + 2.9.9 + 2.8.5 + 1.2.79 + 4.1.1 + 4.1.2 + 1.18.30 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-aop + + + org.springframework + spring-context-support + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-jdbc + + + com.baomidou + mybatis-plus-boot-starter + ${mybatisplus.version} + + + com.baomidou + mybatis-plus-generator + + + + + mysql + mysql-connector-java + ${mysql.version} + + + com.alibaba + druid-spring-boot-starter + ${druid.version} + + + org.quartz-scheduler + quartz + ${quartz.version} + + + com.mchange + c3p0 + + + + + commons-lang + commons-lang + ${commons.lang.version} + + + commons-fileupload + commons-fileupload + ${commons.fileupload.version} + + + commons-io + commons-io + ${commons.io.version} + + + commons-codec + commons-codec + ${commons.codec.version} + + + commons-configuration + commons-configuration + ${commons.configuration.version} + + + org.apache.httpcomponents + httpcore + + + org.apache.shiro + shiro-core + ${shiro.version} + + + org.apache.shiro + shiro-spring + ${shiro.version} + + + io.jsonwebtoken + jjwt + ${jwt.version} + + + io.springfox + springfox-swagger2 + ${swagger.version} + + + io.springfox + springfox-swagger-ui + ${swagger.version} + + + joda-time + joda-time + ${joda.time.version} + + + com.google.code.gson + gson + ${gson.version} + + + com.alibaba + fastjson + ${fastjson.version} + + + cn.hutool + hutool-all + ${hutool.version} + + + org.apache.poi + poi-ooxml + ${poi.version} + + + org.projectlombok + lombok + ${lombok.version} + + + + com.github.wechatpay-apiv3 + wechatpay-java + 0.2.14 + + + com.github.penggle + kaptcha + 2.3.2 + + + junit + junit + test + + + + + daycard-openapi + + + org.apache.maven.plugins + maven-compiler-plugin + + 1.8 + 1.8 + + + org.projectlombok + lombok + ${lombok.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + io.as.AsApplication + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + + + public + aliyun nexus + https://maven.aliyun.com/repository/public/ + + true + + + + + + public + aliyun nexus + https://maven.aliyun.com/repository/public/ + + true + + + false + + + + + diff --git a/src/main/java/io/as/AsApplication.java b/src/main/java/io/as/AsApplication.java new file mode 100644 index 0000000..6d9705c --- /dev/null +++ b/src/main/java/io/as/AsApplication.java @@ -0,0 +1,18 @@ +package io.as; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; + +/** + * 天卡扫码付费开放API 启动类 + */ +@SpringBootApplication +@EnableAsync +public class AsApplication { + + public static void main(String[] args) { + SpringApplication.run(AsApplication.class, args); + } + +} diff --git a/src/main/java/io/as/common/annotation/AuditAction.java b/src/main/java/io/as/common/annotation/AuditAction.java new file mode 100755 index 0000000..b7ed988 --- /dev/null +++ b/src/main/java/io/as/common/annotation/AuditAction.java @@ -0,0 +1,22 @@ +package io.as.common.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 审计日志注解:标记需写入 audit_log 的操作(分配/销售/转合同/绑定/解绑/激活校验/迁移/设备转门店/禁用等) + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface AuditAction { + + /** 动作类型:如 ALLOCATE、TRANSFER、ORG_*、CARD_*、DEVICE_*、DEVICE_TRANSFER_STORE 等 */ + String value(); + + /** 中文描述,如 分配/销售、转合同 */ + String description() default ""; +} diff --git a/src/main/java/io/as/common/annotation/SysLog.java b/src/main/java/io/as/common/annotation/SysLog.java new file mode 100755 index 0000000..bee308d --- /dev/null +++ b/src/main/java/io/as/common/annotation/SysLog.java @@ -0,0 +1,15 @@ +package io.as.common.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface SysLog { + + String value() default ""; +} diff --git a/src/main/java/io/as/common/aspect/ApiLogAspect.java b/src/main/java/io/as/common/aspect/ApiLogAspect.java new file mode 100755 index 0000000..92d9312 --- /dev/null +++ b/src/main/java/io/as/common/aspect/ApiLogAspect.java @@ -0,0 +1,121 @@ +package io.as.common.aspect; + +import com.google.gson.Gson; +import io.as.common.utils.HttpContextUtils; +import org.apache.commons.lang.StringUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.lang.reflect.Method; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * 在 Controller 方法执行后把元数据放入 request;sys_api_log 在 ApiJstCallLogFilter#finally + * 中写入。POST/PUT/PATCH 的 body 由 Filter 在进链前整段预读,见 {@link #ATTR_PREREAD_REQUEST_BODY}。 + *

入站 {@code /api/jst/*} 与其它 {@code io.as.modules.openapi.controller} 接口均落 sys_api_log; + * {@code POST /api/jst/heartBeat} 不落库(轮询频繁);{@code POST /api/jst/tracking} 仅落 device_tracking_log。

+ */ +@Aspect +@Component +public class ApiLogAspect { + + public static final String ATTR_SKIP_SYS_API_LOG = "io.as.api.SYS_API_LOG_SKIP"; + public static final String ATTR_PENDING_SYS_API_LOG = "io.as.api.SYS_API_LOG_PENDING"; + /** Filter 在进链前完整读取的 body,落 sys_api_log 时优先用,避免与 ContentCaching 时序/读空冲突 */ + public static final String ATTR_PREREAD_REQUEST_BODY = "io.as.api.PREREAD_REQUEST_BODY"; + + /** 供 Filter 落库;params 在 Filter 用 body 字节 */ + public static final class PendingSysApiLog { + public long durationMs; + public String resultJson; + public String apiName; + public String methodFull; + /** body 仍为空时退路(可能少于原始 JSON) */ + public String gsonArgsFallback; + } + + private static final Gson GSON = new Gson(); + + @Pointcut("execution(* io.as.modules.openapi.controller..*.*(..))") + public void logPointCut() { + } + + @Around("logPointCut()") + public Object around(ProceedingJoinPoint point) throws Throwable { + HttpServletRequest request = HttpContextUtils.getHttpServletRequest(); + if (shouldSkipSysApiLogForJstInbound(request)) { + request.setAttribute(ATTR_SKIP_SYS_API_LOG, Boolean.TRUE); + return point.proceed(); + } + + long beginTime = System.currentTimeMillis(); + Object result = point.proceed(); + long time = System.currentTimeMillis() - beginTime; + + PendingSysApiLog pending = new PendingSysApiLog(); + pending.durationMs = time; + MethodSignature signature = (MethodSignature) point.getSignature(); + Method method = signature.getMethod(); + pending.apiName = method.getName(); + String className = point.getTarget().getClass().getName(); + pending.methodFull = className + "." + signature.getName() + "()"; + try { + pending.resultJson = GSON.toJson(result); + } catch (Exception e) { + pending.resultJson = null; + } + pending.gsonArgsFallback = gsonToFilteredArgsJson(point.getArgs()); + request.setAttribute(ATTR_PENDING_SYS_API_LOG, pending); + return result; + } + + /** 心跳/埋点:不落 sys_api_log(埋点数据写入 device_tracking_log) */ + private static boolean shouldSkipSysApiLogForJstInbound(HttpServletRequest request) { + String rel = requestPathWithoutContext(request); + return "/api/jst/heartBeat".equals(rel) || "/api/jst/tracking".equals(rel); + } + + private static String requestPathWithoutContext(HttpServletRequest request) { + String uri = request.getRequestURI(); + if (uri == null) { + return ""; + } + String ctx = request.getContextPath() == null ? "" : request.getContextPath(); + return uri.startsWith(ctx) ? uri.substring(ctx.length()) : uri; + } + + private static String gsonToFilteredArgsJson(Object[] args) { + List filteredArgs = new ArrayList<>(); + if (args != null) { + for (Object arg : args) { + if (arg != null && !(arg instanceof HttpServletRequest) && !(arg instanceof HttpServletResponse)) { + filteredArgs.add(arg); + } + } + } + return GSON.toJson(filteredArgs); + } + + public static Charset requestCharsetOrUtf8(HttpServletRequest r) { + if (r == null) { + return StandardCharsets.UTF_8; + } + String enc = r.getCharacterEncoding(); + if (StringUtils.isNotBlank(enc)) { + try { + return Charset.forName(enc); + } catch (Exception ignored) { + } + } + return StandardCharsets.UTF_8; + } +} diff --git a/src/main/java/io/as/common/aspect/AuditLogAspect.java b/src/main/java/io/as/common/aspect/AuditLogAspect.java new file mode 100755 index 0000000..a7ae743 --- /dev/null +++ b/src/main/java/io/as/common/aspect/AuditLogAspect.java @@ -0,0 +1,128 @@ +package io.as.common.aspect; + +import com.google.gson.Gson; +import io.as.common.annotation.AuditAction; +import io.as.common.utils.HttpContextUtils; +import io.as.common.utils.IPUtils; +import io.as.modules.sys.entity.AuditLogEntity; +import io.as.modules.sys.entity.SysUserEntity; +import io.as.modules.sys.service.AuditLogService; +import org.apache.shiro.SecurityUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import java.lang.reflect.Method; +import java.util.Date; + +/** + * 审计日志切面:将标记了 @AuditAction 的接口调用记录到 audit_log 表 + * 用于:分配/销售、转合同、绑定、解绑、激活校验、迁移、设备转门店、禁用等 + */ +@Aspect +@Component +public class AuditLogAspect { + + @Autowired + private AuditLogService auditLogService; + + private static final Gson GSON = new Gson(); + private static final int PARAM_MAX_LENGTH = 2000; + private static final int RESULT_MAX_LENGTH = 500; + + @Pointcut("@annotation(io.as.common.annotation.AuditAction)") + public void auditPointCut() { + } + + @Around("auditPointCut()") + public Object around(ProceedingJoinPoint point) throws Throwable { + MethodSignature signature = (MethodSignature) point.getSignature(); + Method method = signature.getMethod(); + AuditAction auditAction = method.getAnnotation(AuditAction.class); + if (auditAction == null) { + return point.proceed(); + } + + String api = point.getTarget().getClass().getSimpleName() + "#" + method.getName(); + String paramJson = toParamJson(point.getArgs()); + String result = "success"; + try { + Object ret = point.proceed(); + if (ret != null && ret.toString().length() <= RESULT_MAX_LENGTH) { + result = ret.toString(); + } + return ret; + } catch (Throwable t) { + result = "exception: " + (t.getMessage() != null ? t.getMessage() : t.getClass().getSimpleName()); + if (result.length() > RESULT_MAX_LENGTH) { + result = result.substring(0, RESULT_MAX_LENGTH); + } + throw t; + } finally { + saveAuditLog(auditAction, api, paramJson, result, getRequest()); + } + } + + private void saveAuditLog(AuditAction auditAction, String api, String paramJson, String result, HttpServletRequest request) { + try { + AuditLogEntity log = new AuditLogEntity(); + log.setHead(auditAction.description() != null && !auditAction.description().isEmpty() + ? auditAction.description() : auditAction.value()); + log.setMethod(auditAction.value()); + log.setApi(api); + log.setParam(paramJson != null && paramJson.length() > PARAM_MAX_LENGTH + ? paramJson.substring(0, PARAM_MAX_LENGTH) : paramJson); + log.setResult(result); + + try { + Object principal = SecurityUtils.getSubject().getPrincipal(); + if (principal instanceof SysUserEntity) { + log.setActorUserId(((SysUserEntity) principal).getId()); + } + } catch (Exception ignored) { + } + + if (request != null) { + log.setIp(IPUtils.getIpAddr(request)); + String ua = request.getHeader("User-Agent"); + if (ua != null && ua.length() > 255) { + ua = ua.substring(0, 255); + } + log.setDeviceInfo(ua); + } + + Date now = new Date(); + log.setCreatedAt(now); + log.setUpdatedAt(now); + auditLogService.save(log); + } catch (Exception e) { + // 审计写库失败不影响主流程 + org.slf4j.LoggerFactory.getLogger(AuditLogAspect.class).warn("audit_log save fail: {}", e.getMessage()); + } + } + + private String toParamJson(Object[] args) { + if (args == null || args.length == 0) { + return null; + } + try { + // 简单序列化;可扩展为脱敏 password 等字段 + return GSON.toJson(args); + } catch (Exception e) { + return "[serialize error]"; + } + } + + private HttpServletRequest getRequest() { + try { + return HttpContextUtils.getHttpServletRequest(); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/io/as/common/aspect/RedisAspect.java b/src/main/java/io/as/common/aspect/RedisAspect.java new file mode 100755 index 0000000..776eaad --- /dev/null +++ b/src/main/java/io/as/common/aspect/RedisAspect.java @@ -0,0 +1,38 @@ +package io.as.common.aspect; + +import io.as.common.exception.ASException; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +/** + * Redis切面处理类 + * + * @author Mark sunlightcs@gmail.com + */ +@Aspect +@Configuration +public class RedisAspect { + private Logger logger = LoggerFactory.getLogger(getClass()); + //是否开启redis缓存 true开启 false关闭 + @Value("${spring.redis.open: false}") + private boolean open; + + @Around("execution(* io.as.common.utils.RedisUtils.*(..))") + public Object around(ProceedingJoinPoint point) throws Throwable { + Object result = null; + if(open){ + try{ + result = point.proceed(); + }catch (Exception e){ + logger.error("redis error", e); + throw new ASException("Redis服务异常"); + } + } + return result; + } +} diff --git a/src/main/java/io/as/common/aspect/SysLogAspect.java b/src/main/java/io/as/common/aspect/SysLogAspect.java new file mode 100755 index 0000000..8b13789 --- /dev/null +++ b/src/main/java/io/as/common/aspect/SysLogAspect.java @@ -0,0 +1 @@ + diff --git a/src/main/java/io/as/common/exception/ASException.java b/src/main/java/io/as/common/exception/ASException.java new file mode 100755 index 0000000..2944d58 --- /dev/null +++ b/src/main/java/io/as/common/exception/ASException.java @@ -0,0 +1,52 @@ +package io.as.common.exception; + +/** + * 自定义异常 + * + * @author Mark sunlightcs@gmail.com + */ +public class ASException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private String msg; + private int code = 500; + + public ASException(String msg) { + super(msg); + this.msg = msg; + } + + public ASException(String msg, Throwable e) { + super(msg, e); + this.msg = msg; + } + + public ASException(String msg, int code) { + super(msg); + this.msg = msg; + this.code = code; + } + + public ASException(String msg, int code, Throwable e) { + super(msg, e); + this.msg = msg; + this.code = code; + } + + public String getMsg() { + return msg; + } + + public void setMsg(String msg) { + this.msg = msg; + } + + public int getCode() { + return code; + } + + public void setCode(int code) { + this.code = code; + } + +} diff --git a/src/main/java/io/as/common/exception/ASExceptionHandler.java b/src/main/java/io/as/common/exception/ASExceptionHandler.java new file mode 100755 index 0000000..615cb11 --- /dev/null +++ b/src/main/java/io/as/common/exception/ASExceptionHandler.java @@ -0,0 +1,66 @@ +package io.as.common.exception; + +import io.as.common.utils.R; +import org.apache.shiro.authz.AuthorizationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.transaction.interceptor.TransactionAspectSupport; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; + +/** + * 异常处理器 + * + * @author Mark sunlightcs@gmail.com + */ +@RestControllerAdvice +public class ASExceptionHandler { + private Logger logger = LoggerFactory.getLogger(getClass()); + + /** + * 处理自定义异常 + * 显式标记事务回滚,确保业务异常时数据库操作能正确回滚 + */ + @ExceptionHandler(ASException.class) + public R handleASException(ASException e){ + try { + org.springframework.transaction.TransactionStatus status = TransactionAspectSupport.currentTransactionStatus(); + if (status != null) { + status.setRollbackOnly(); + } + } catch (Exception ignored) { + // 无活跃事务时忽略 + } + R r = new R(); + r.put("code", e.getCode()); + r.put("msg", e.getMessage()); + r.put("data", new java.util.HashMap<>()); + return r; + } + + @ExceptionHandler(NoHandlerFoundException.class) + public R handlerNoFoundException(Exception e) { + logger.error(e.getMessage(), e); + return R.error(404, "路径不存在,请检查路径是否正确"); + } + + @ExceptionHandler(DuplicateKeyException.class) + public R handleDuplicateKeyException(DuplicateKeyException e){ + logger.error(e.getMessage(), e); + return R.error("数据库中已存在该记录"); + } + + @ExceptionHandler(AuthorizationException.class) + public R handleAuthorizationException(AuthorizationException e){ + logger.error(e.getMessage(), e); + return R.error("没有权限,请联系管理员授权"); + } + + @ExceptionHandler(Exception.class) + public R handleException(Exception e){ + logger.error(e.getMessage(), e); + return R.error(); + } +} diff --git a/src/main/java/io/as/common/utils/BeanFindUtils.java b/src/main/java/io/as/common/utils/BeanFindUtils.java new file mode 100755 index 0000000..7ca7e1c --- /dev/null +++ b/src/main/java/io/as/common/utils/BeanFindUtils.java @@ -0,0 +1,65 @@ +package io.as.common.utils; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.core.type.filter.AssignableTypeFilter; + +import java.lang.annotation.Annotation; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 查询特定的类,该工具下的方法应该在程序启动完成之后才可以使用 + */ +public class BeanFindUtils { + + private static ClassPathScanningCandidateComponentProvider scanner; + + /** + * 查询指定了特定注解的类 + * @param basePackage 类所存放的包名 + * @param annotation 特定的注解 + * @return 目标类 + */ + public static Set> findAnnotation(String basePackage, Class annotation) { + ClassPathScanningCandidateComponentProvider scanner = getInstance(); + scanner.addIncludeFilter(new AnnotationTypeFilter(annotation)); + return getClasses(basePackage, scanner); + } + + /** + * 查询实现了特定接口的类 + * @param basePackage 类所存放的包名 + * @param interfaceClass 特定的接口 + * @return 目标类 + */ + public static Set> findInterface(String basePackage, Class interfaceClass) { + ClassPathScanningCandidateComponentProvider scanner = getInstance(); + scanner.addIncludeFilter(new AssignableTypeFilter(interfaceClass)); + return getClasses(basePackage, scanner); + } + + + private static Set> getClasses(String basePackage, ClassPathScanningCandidateComponentProvider scanner) { + Set beanDefinitions = scanner.findCandidateComponents(basePackage); + return beanDefinitions.stream() + .map(BeanDefinition::getBeanClassName) + .map(className -> { + try { + return Class.forName(className); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.toSet()); + } + + private static ClassPathScanningCandidateComponentProvider getInstance(){ + if (scanner == null){ + scanner = new ClassPathScanningCandidateComponentProvider(false); + } + return scanner; + } + +} \ No newline at end of file diff --git a/src/main/java/io/as/common/utils/ConfigConstant.java b/src/main/java/io/as/common/utils/ConfigConstant.java new file mode 100755 index 0000000..358ddc8 --- /dev/null +++ b/src/main/java/io/as/common/utils/ConfigConstant.java @@ -0,0 +1,18 @@ +package io.as.common.utils; + +/** + * 系统参数相关Key + * + * @author Mark sunlightcs@gmail.com + */ +public class ConfigConstant { + /** + * 云存储配置KEY + */ + public final static String CLOUD_STORAGE_CONFIG_KEY = "CLOUD_STORAGE_CONFIG_KEY"; + + /** + * 橘视厅/聚视厅第三方平台配置KEY(param_value 为 JSON) + */ + public final static String JST_PLATFORM_CONFIG = "JST_PLATFORM_CONFIG"; +} diff --git a/src/main/java/io/as/common/utils/Constant.java b/src/main/java/io/as/common/utils/Constant.java new file mode 100755 index 0000000..e055aba --- /dev/null +++ b/src/main/java/io/as/common/utils/Constant.java @@ -0,0 +1,146 @@ +package io.as.common.utils; + +import io.as.common.validator.group.QcloudGroup; + +import java.util.Optional; +import java.util.stream.Stream; + +/** + * 常量 + * + * @author Mark sunlightcs@gmail.com + */ +public class Constant { + /** + * 超级管理员ID + */ + public static final int SUPER_ADMIN = 1; + + /** + * 超级管理员ID数组 + */ + public static final long[] SUPER_ADMIN_IDS = {1}; + + /** + * 判断是否为超级管理员(根据 orgId 是否为 null 判断) + */ + public static boolean isSuperAdmin(Long orgId) { + return orgId == null; + } + /** + * 当前页码 + */ + public static final String PAGE = "page"; + /** + * 每页显示记录数 + */ + public static final String LIMIT = "limit"; + /** + * 排序字段 + */ + public static final String ORDER_FIELD = "sidx"; + /** + * 排序方式 + */ + public static final String ORDER = "order"; + /** + * 升序 + */ + public static final String ASC = "asc"; + + /** + * 菜单类型 + * + * @author chenshun + * @email sunlightcs@gmail.com + * @date 2016年11月15日 下午1:24:29 + */ + public enum MenuType { + /** + * 目录 + */ + CATALOG(0), + /** + * 菜单 + */ + MENU(1), + /** + * 按钮 + */ + BUTTON(2); + + private int value; + + MenuType(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + } + + /** + * 定时任务状态 + * + * @author chenshun + * @email sunlightcs@gmail.com + * @date 2016年12月3日 上午12:07:22 + */ + public enum ScheduleStatus { + /** + * 正常 + */ + NORMAL(0), + /** + * 暂停 + */ + PAUSE(1); + + private int value; + + ScheduleStatus(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + } + + /** + * 云服务商 + */ + public enum CloudService { + /** + * 腾讯云 COS + */ + QCLOUD(3, QcloudGroup.class); + + private int value; + + private Class validatorGroupClass; + + CloudService(int value, Class validatorGroupClass) { + this.value = value; + this.validatorGroupClass = validatorGroupClass; + } + + public int getValue() { + return value; + } + + public Class getValidatorGroupClass() { + return this.validatorGroupClass; + } + + public static CloudService getByValue(Integer value) { + Optional first = Stream.of(CloudService.values()).filter(cs -> value.equals(cs.value)).findFirst(); + if (!first.isPresent()) { + throw new IllegalArgumentException("非法的枚举值:" + value); + } + return first.get(); + } + } + +} diff --git a/src/main/java/io/as/common/utils/CustomHeaderCaptureUtils.java b/src/main/java/io/as/common/utils/CustomHeaderCaptureUtils.java new file mode 100755 index 0000000..65d8767 --- /dev/null +++ b/src/main/java/io/as/common/utils/CustomHeaderCaptureUtils.java @@ -0,0 +1,70 @@ +package io.as.common.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import javax.servlet.http.HttpServletRequest; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 采集请求中的自定义 header(X- 开头、sign 等),序列化为 JSON,供接口日志使用。 + */ +public final class CustomHeaderCaptureUtils { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private CustomHeaderCaptureUtils() { + } + + /** + * @param maxJsonLength 整段 JSON 最大长度,超出截断 + */ + public static String captureAsJson(HttpServletRequest request, int maxJsonLength, int maxValueLength) { + if (request == null) { + return null; + } + try { + Map headers = new LinkedHashMap<>(); + Enumeration names = request.getHeaderNames(); + if (names == null) { + return null; + } + while (names.hasMoreElements()) { + String name = names.nextElement(); + if (name == null) { + continue; + } + String n = name.toLowerCase(); + if ("sign".equals(n) || n.startsWith("x-")) { + String value = request.getHeader(name); + if (value != null) { + headers.put(name, truncate(value, maxValueLength)); + } + } + } + if (headers.isEmpty()) { + return null; + } + String json = MAPPER.writeValueAsString(headers); + return truncate(json, maxJsonLength); + } catch (Exception e) { + return null; + } + } + + /** 与 api_jst_call_log.request_headers 一致的默认上限 */ + public static String captureAsJson(HttpServletRequest request) { + return captureAsJson(request, 2000, 500); + } + + private static String truncate(String s, int maxLen) { + if (s == null) { + return null; + } + if (s.length() <= maxLen) { + return s; + } + return s.substring(0, maxLen) + "..."; + } +} diff --git a/src/main/java/io/as/common/utils/DateUtils.java b/src/main/java/io/as/common/utils/DateUtils.java new file mode 100755 index 0000000..f188b41 --- /dev/null +++ b/src/main/java/io/as/common/utils/DateUtils.java @@ -0,0 +1,158 @@ +package io.as.common.utils; + +import org.apache.commons.lang.StringUtils; +import org.joda.time.DateTime; +import org.joda.time.LocalDate; +import org.joda.time.format.DateTimeFormat; +import org.joda.time.format.DateTimeFormatter; + +import java.text.SimpleDateFormat; +import java.util.Date; + +/** + * 日期处理 + * + * @author Mark sunlightcs@gmail.com + */ +public class DateUtils { + /** 时间格式(yyyy-MM-dd) */ + public final static String DATE_PATTERN = "yyyy-MM-dd"; + /** 时间格式(yyyy-MM-dd HH:mm:ss) */ + public final static String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; + + /** + * 日期格式化 日期格式为:yyyy-MM-dd + * @param date 日期 + * @return 返回yyyy-MM-dd格式日期 + */ + public static String format(Date date) { + return format(date, DATE_PATTERN); + } + + /** + * 日期格式化 日期格式为:yyyy-MM-dd + * @param date 日期 + * @param pattern 格式,如:DateUtils.DATE_TIME_PATTERN + * @return 返回yyyy-MM-dd格式日期 + */ + public static String format(Date date, String pattern) { + if(date != null){ + SimpleDateFormat df = new SimpleDateFormat(pattern); + return df.format(date); + } + return null; + } + + /** + * 字符串转换成日期 + * @param strDate 日期字符串 + * @param pattern 日期的格式,如:DateUtils.DATE_TIME_PATTERN + */ + public static Date stringToDate(String strDate, String pattern) { + if (StringUtils.isBlank(strDate)){ + return null; + } + + DateTimeFormatter fmt = DateTimeFormat.forPattern(pattern); + return fmt.parseLocalDateTime(strDate).toDate(); + } + + /** + * 根据周数,获取开始日期、结束日期 + * @param week 周期 0本周,-1上周,-2上上周,1下周,2下下周 + * @return 返回date[0]开始日期、date[1]结束日期 + */ + public static Date[] getWeekStartAndEnd(int week) { + DateTime dateTime = new DateTime(); + LocalDate date = new LocalDate(dateTime.plusWeeks(week)); + + date = date.dayOfWeek().withMinimumValue(); + Date beginDate = date.toDate(); + Date endDate = date.plusDays(6).toDate(); + return new Date[]{beginDate, endDate}; + } + + /** + * 对日期的【秒】进行加/减 + * + * @param date 日期 + * @param seconds 秒数,负数为减 + * @return 加/减几秒后的日期 + */ + public static Date addDateSeconds(Date date, int seconds) { + DateTime dateTime = new DateTime(date); + return dateTime.plusSeconds(seconds).toDate(); + } + + /** + * 对日期的【分钟】进行加/减 + * + * @param date 日期 + * @param minutes 分钟数,负数为减 + * @return 加/减几分钟后的日期 + */ + public static Date addDateMinutes(Date date, int minutes) { + DateTime dateTime = new DateTime(date); + return dateTime.plusMinutes(minutes).toDate(); + } + + /** + * 对日期的【小时】进行加/减 + * + * @param date 日期 + * @param hours 小时数,负数为减 + * @return 加/减几小时后的日期 + */ + public static Date addDateHours(Date date, int hours) { + DateTime dateTime = new DateTime(date); + return dateTime.plusHours(hours).toDate(); + } + + /** + * 对日期的【天】进行加/减 + * + * @param date 日期 + * @param days 天数,负数为减 + * @return 加/减几天后的日期 + */ + public static Date addDateDays(Date date, int days) { + DateTime dateTime = new DateTime(date); + return dateTime.plusDays(days).toDate(); + } + + /** + * 对日期的【周】进行加/减 + * + * @param date 日期 + * @param weeks 周数,负数为减 + * @return 加/减几周后的日期 + */ + public static Date addDateWeeks(Date date, int weeks) { + DateTime dateTime = new DateTime(date); + return dateTime.plusWeeks(weeks).toDate(); + } + + /** + * 对日期的【月】进行加/减 + * + * @param date 日期 + * @param months 月数,负数为减 + * @return 加/减几月后的日期 + */ + public static Date addDateMonths(Date date, int months) { + DateTime dateTime = new DateTime(date); + return dateTime.plusMonths(months).toDate(); + } + + /** + * 对日期的【年】进行加/减 + * + * @param date 日期 + * @param years 年数,负数为减 + * @return 加/减几年后的日期 + */ + public static Date addDateYears(Date date, int years) { + DateTime dateTime = new DateTime(date); + return dateTime.plusYears(years).toDate(); + } +} diff --git a/src/main/java/io/as/common/utils/HttpContextUtils.java b/src/main/java/io/as/common/utils/HttpContextUtils.java new file mode 100755 index 0000000..1318790 --- /dev/null +++ b/src/main/java/io/as/common/utils/HttpContextUtils.java @@ -0,0 +1,24 @@ +package io.as.common.utils; + +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; + +public class HttpContextUtils { + + public static HttpServletRequest getHttpServletRequest() { + return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); + } + + public static String getDomain(){ + HttpServletRequest request = getHttpServletRequest(); + StringBuffer url = request.getRequestURL(); + return url.delete(url.length() - request.getRequestURI().length(), url.length()).toString(); + } + + public static String getOrigin(){ + HttpServletRequest request = getHttpServletRequest(); + return request.getHeader("Origin"); + } +} diff --git a/src/main/java/io/as/common/utils/IPUtils.java b/src/main/java/io/as/common/utils/IPUtils.java new file mode 100755 index 0000000..05e76db --- /dev/null +++ b/src/main/java/io/as/common/utils/IPUtils.java @@ -0,0 +1,56 @@ +package io.as.common.utils; + +import com.alibaba.druid.util.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.http.HttpServletRequest; + +/** + * IP地址 + * + * @author Mark sunlightcs@gmail.com + */ +public class IPUtils { + private static Logger logger = LoggerFactory.getLogger(IPUtils.class); + + /** + * 获取IP地址 + * + * 使用Nginx等反向代理软件, 则不能通过request.getRemoteAddr()获取IP地址 + * 如果使用了多级反向代理的话,X-Forwarded-For的值并不止一个,而是一串IP地址,X-Forwarded-For中第一个非unknown的有效IP字符串,则为真实IP地址 + */ + public static String getIpAddr(HttpServletRequest request) { + String ip = null; + try { + ip = request.getHeader("x-forwarded-for"); + if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (StringUtils.isEmpty(ip) || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("HTTP_CLIENT_IP"); + } + if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("HTTP_X_FORWARDED_FOR"); + } + if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + } catch (Exception e) { + logger.error("IPUtils ERROR ", e); + } + +// //使用代理,则获取第一个IP地址 +// if(StringUtils.isEmpty(ip) && ip.length() > 15) { +// if(ip.indexOf(",") > 0) { +// ip = ip.substring(0, ip.indexOf(",")); +// } +// } + + return ip; + } + +} diff --git a/src/main/java/io/as/common/utils/MacAddressUtils.java b/src/main/java/io/as/common/utils/MacAddressUtils.java new file mode 100755 index 0000000..90263fb --- /dev/null +++ b/src/main/java/io/as/common/utils/MacAddressUtils.java @@ -0,0 +1,151 @@ +package io.as.common.utils; + +import org.apache.commons.lang.StringUtils; + +/** + * MAC 地址工具类 + * 用于校验 MAC 地址的合法性 + */ +public class MacAddressUtils { + + /** + * 校验 MAC 地址是否合法(单播地址,可作为源地址) + * + * 核心规则: + * 1. 第一字节的最低位(I/G位)必须是 0 - 代表单播地址 + * - 0:单播(Individual),可作为源地址 + * - 1:组播(Group),只能作为目标地址,不能作为源地址 + * 2. 不能是全0地址(00:00:00:00:00:00) + * 3. 不能是全F地址(FF:FF:FF:FF:FF:FF,广播地址) + * + * @param mac MAC 地址字符串(支持格式:AA:BB:CC:DD:EE:FF 或 AA-BB-CC-DD-EE-FF 或 AABBCCDDEEFF) + * @return true-合法的单播MAC地址,false-非法 + */ + public static boolean isValidUnicastMac(String mac) { + if (StringUtils.isBlank(mac)) { + return true; // 空值不校验,由调用方决定 + } + + // 标准化 MAC 地址(去除分隔符,转为大写) + String normalizedMac = normalizeMac(mac); + if (normalizedMac == null || normalizedMac.length() != 12) { + return false; // 格式错误 + } + + // 检查是否全0 + if ("000000000000".equals(normalizedMac)) { + return false; // 全0地址无效 + } + + // 检查是否全F(广播地址) + if ("FFFFFFFFFFFF".equalsIgnoreCase(normalizedMac)) { + return false; // 广播地址无效 + } + + // 获取第一字节 + int firstByte; + try { + firstByte = Integer.parseInt(normalizedMac.substring(0, 2), 16); + } catch (NumberFormatException e) { + return false; // 解析错误 + } + + // 检查 I/G 位(最低位):必须为0(单播) + // 第一字节的二进制:xxxx xxx0 + if ((firstByte & 0x01) != 0) { + return false; // I/G位为1,是组播地址,不能作为源地址 + } + + return true; + } + + /** + * 获取 MAC 地址类型描述 + * + * @param mac MAC 地址字符串 + * @return 地址类型描述 + */ + public static String getMacAddressType(String mac) { + if (StringUtils.isBlank(mac)) { + return "空地址"; + } + + String normalizedMac = normalizeMac(mac); + if (normalizedMac == null || normalizedMac.length() != 12) { + return "格式错误"; + } + + // 检查是否全0 + if ("000000000000".equals(normalizedMac)) { + return "全0地址(无效)"; + } + + // 检查是否全F(广播地址) + if ("FFFFFFFFFFFF".equalsIgnoreCase(normalizedMac)) { + return "广播地址(无效)"; + } + + int firstByte; + try { + firstByte = Integer.parseInt(normalizedMac.substring(0, 2), 16); + } catch (NumberFormatException e) { + return "格式错误"; + } + + boolean isMulticast = (firstByte & 0x01) != 0; + boolean isLocalAdmin = (firstByte & 0x02) != 0; + + if (isMulticast) { + return "组播地址(只能作为目标地址)"; + } else if (isLocalAdmin) { + return "本地管理单播地址(LAA)"; + } else { + return "全球唯一单播地址(UAA)"; + } + } + + /** + * 标准化 MAC 地址 + * 去除分隔符(: - .),转为大写,保留12位十六进制字符 + * + * @param mac 原始 MAC 地址 + * @return 标准化后的 MAC 地址(12位大写十六进制字符串),如果格式错误返回 null + */ + public static String normalizeMac(String mac) { + if (StringUtils.isBlank(mac)) { + return null; + } + + // 去除所有分隔符和空白 + String cleaned = mac.replaceAll("[:.\\s-]", "").toUpperCase(); + + // 验证是否为有效的十六进制字符串 + if (!cleaned.matches("^[0-9A-F]{12}$")) { + return null; + } + + return cleaned; + } + + /** + * 格式化 MAC 地址为冒号分隔格式 + * + * @param mac 原始 MAC 地址 + * @return AA:BB:CC:DD:EE:FF 格式的 MAC 地址 + */ + public static String formatMac(String mac) { + String normalized = normalizeMac(mac); + if (normalized == null) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 12; i += 2) { + if (i > 0) { + sb.append(':'); + } + sb.append(normalized.substring(i, i + 2)); + } + return sb.toString(); + } +} diff --git a/src/main/java/io/as/common/utils/MapUtils.java b/src/main/java/io/as/common/utils/MapUtils.java new file mode 100755 index 0000000..7cca44c --- /dev/null +++ b/src/main/java/io/as/common/utils/MapUtils.java @@ -0,0 +1,18 @@ +package io.as.common.utils; + +import java.util.HashMap; + + +/** + * Map工具类 + * + * @author Mark sunlightcs@gmail.com + */ +public class MapUtils extends HashMap { + + @Override + public MapUtils put(String key, Object value) { + super.put(key, value); + return this; + } +} diff --git a/src/main/java/io/as/common/utils/PageUtils.java b/src/main/java/io/as/common/utils/PageUtils.java new file mode 100755 index 0000000..27bbd1c --- /dev/null +++ b/src/main/java/io/as/common/utils/PageUtils.java @@ -0,0 +1,102 @@ +package io.as.common.utils; + +import com.baomidou.mybatisplus.core.metadata.IPage; + +import java.io.Serializable; +import java.util.List; + +/** + * 分页工具类 + * + * @author Mark sunlightcs@gmail.com + */ +public class PageUtils implements Serializable { + private static final long serialVersionUID = 1L; + /** + * 总记录数 + */ + private int totalCount; + /** + * 每页记录数 + */ + private int pageSize; + /** + * 总页数 + */ + private int totalPage; + /** + * 当前页数 + */ + private int currPage; + /** + * 列表数据 + */ + private List list; + + /** + * 分页 + * @param list 列表数据 + * @param totalCount 总记录数 + * @param pageSize 每页记录数 + * @param currPage 当前页数 + */ + public PageUtils(List list, int totalCount, int pageSize, int currPage) { + this.list = list; + this.totalCount = totalCount; + this.pageSize = pageSize; + this.currPage = currPage; + this.totalPage = (int)Math.ceil((double)totalCount/pageSize); + } + + /** + * 分页 + */ + public PageUtils(IPage page) { + this.list = page.getRecords(); + this.totalCount = (int)page.getTotal(); + this.pageSize = (int)page.getSize(); + this.currPage = (int)page.getCurrent(); + this.totalPage = (int)page.getPages(); + } + + public int getTotalCount() { + return totalCount; + } + + public void setTotalCount(int totalCount) { + this.totalCount = totalCount; + } + + public int getPageSize() { + return pageSize; + } + + public void setPageSize(int pageSize) { + this.pageSize = pageSize; + } + + public int getTotalPage() { + return totalPage; + } + + public void setTotalPage(int totalPage) { + this.totalPage = totalPage; + } + + public int getCurrPage() { + return currPage; + } + + public void setCurrPage(int currPage) { + this.currPage = currPage; + } + + public List getList() { + return list; + } + + public void setList(List list) { + this.list = list; + } + +} diff --git a/src/main/java/io/as/common/utils/Query.java b/src/main/java/io/as/common/utils/Query.java new file mode 100755 index 0000000..dce7247 --- /dev/null +++ b/src/main/java/io/as/common/utils/Query.java @@ -0,0 +1,69 @@ +package io.as.common.utils; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.OrderItem; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import io.as.common.xss.SQLFilter; +import org.apache.commons.lang.StringUtils; + +import java.util.Map; + +/** + * 查询参数 + * + * @author Mark sunlightcs@gmail.com + */ +public class Query { + + public IPage getPage(Map params) { + return this.getPage(params, null, false); + } + + public IPage getPage(Map params, String defaultOrderField, boolean isAsc) { + //分页参数 + long curPage = 1; + long limit = 10; + + if(params.get(Constant.PAGE) != null){ + curPage = Long.parseLong((String)params.get(Constant.PAGE)); + } + if(params.get(Constant.LIMIT) != null){ + limit = Long.parseLong((String)params.get(Constant.LIMIT)); + } + + //分页对象 + Page page = new Page<>(curPage, limit); + + //分页参数 + params.put(Constant.PAGE, page); + + //排序字段 + //防止SQL注入(因为sidx、order是通过拼接SQL实现排序的,会有SQL注入风险) + String orderField = SQLFilter.sqlInject((String)params.get(Constant.ORDER_FIELD)); + String order = (String)params.get(Constant.ORDER); + + + //前端字段排序 + if(StringUtils.isNotEmpty(orderField) && StringUtils.isNotEmpty(order)){ + if(Constant.ASC.equalsIgnoreCase(order)) { + return page.addOrder(OrderItem.asc(orderField)); + }else { + return page.addOrder(OrderItem.desc(orderField)); + } + } + + //没有排序字段,则不排序 + if(StringUtils.isBlank(defaultOrderField)){ + return page; + } + + //默认排序 + if(isAsc) { + page.addOrder(OrderItem.asc(defaultOrderField)); + }else { + page.addOrder(OrderItem.desc(defaultOrderField)); + } + + return page; + } +} diff --git a/src/main/java/io/as/common/utils/R.java b/src/main/java/io/as/common/utils/R.java new file mode 100755 index 0000000..cd6491f --- /dev/null +++ b/src/main/java/io/as/common/utils/R.java @@ -0,0 +1,99 @@ +package io.as.common.utils; + +import org.apache.http.HttpStatus; + +import java.util.HashMap; +import java.util.Map; + +/** + * 返回数据 + * + * @author Mark sunlightcs@gmail.com + */ +public class R extends HashMap { + private static final long serialVersionUID = 1L; + + public R() { + put("code", 0); + put("msg", "success"); + } + + public static R error() { + return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, "未知异常,请联系管理员"); + } + + public static R error(String msg) { + return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, msg); + } + + public static R error(int code, String msg) { + R r = new R(); + r.put("code", code); + r.put("msg", msg); + return r; + } + + public static R ok(String msg) { + R r = new R(); + r.put("msg", msg); + return r; + } + + public static R ok(Map map) { + R r = new R(); + r.putAll(map); + return r; + } + + public static R ok() { + return new R(); + } + + /** + * App 等统一响应:成功时 {@code { "code": 200, "msg": "", "data": {...} }} + */ + public static R dataOk() { + return dataOk(new HashMap()); + } + + /** + * App 等统一响应:成功时 payload 放在 {@code data} 内。 + */ + public static R dataOk(Map data) { + R r = new R(); + r.clear(); + r.put("code", 200); + r.put("msg", ""); + r.put("data", data != null ? data : new HashMap()); + return r; + } + + /** + * 统一响应(code 为 0):成功时 {@code { "code": 0, "msg": "", "data": {...} }} + */ + public static R dataSuccess(Map data) { + R r = new R(); + r.clear(); + r.put("code", 0); + r.put("msg", ""); + r.put("data", data != null ? data : new HashMap()); + return r; + } + + /** + * App 等统一响应:失败时 {@code { "code": code, "msg": msg, "data": {} }} + */ + public static R dataError(int code, String msg) { + R r = new R(); + r.clear(); + r.put("code", code); + r.put("msg", msg != null ? msg : ""); + r.put("data", new HashMap()); + return r; + } + + public R put(String key, Object value) { + super.put(key, value); + return this; + } +} diff --git a/src/main/java/io/as/common/utils/RedisKeys.java b/src/main/java/io/as/common/utils/RedisKeys.java new file mode 100755 index 0000000..88d191e --- /dev/null +++ b/src/main/java/io/as/common/utils/RedisKeys.java @@ -0,0 +1,13 @@ +package io.as.common.utils; + +/** + * Redis所有Keys + * + * @author Mark sunlightcs@gmail.com + */ +public class RedisKeys { + + public static String getSysConfigKey(String key){ + return "sys:config:" + key; + } +} diff --git a/src/main/java/io/as/common/utils/RedisUtils.java b/src/main/java/io/as/common/utils/RedisUtils.java new file mode 100755 index 0000000..00d64eb --- /dev/null +++ b/src/main/java/io/as/common/utils/RedisUtils.java @@ -0,0 +1,112 @@ +package io.as.common.utils; + +import com.google.gson.Gson; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.*; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +/** + * Redis工具类 + * + * @author Mark sunlightcs@gmail.com + */ +@Component +public class RedisUtils { + @Autowired + private RedisTemplate redisTemplate; + @Autowired + private ValueOperations valueOperations; + @Autowired + private HashOperations hashOperations; + @Autowired + private ListOperations listOperations; + @Autowired + private SetOperations setOperations; + @Autowired + private ZSetOperations zSetOperations; + /** 默认过期时长,单位:秒 */ + public final static long DEFAULT_EXPIRE = 60 * 60 * 24; + /** 不设置过期时长 */ + public final static long NOT_EXPIRE = -1; + private final static Gson gson = new Gson(); + + public void set(String key, Object value, long expire){ + valueOperations.set(key, toJson(value)); + if(expire != NOT_EXPIRE){ + redisTemplate.expire(key, expire, TimeUnit.SECONDS); + } + } + + public void set(String key, Object value){ + set(key, value, DEFAULT_EXPIRE); + } + + public T get(String key, Class clazz, long expire) { + String value = valueOperations.get(key); + if(expire != NOT_EXPIRE){ + redisTemplate.expire(key, expire, TimeUnit.SECONDS); + } + return value == null ? null : fromJson(value, clazz); + } + + public T get(String key, Class clazz) { + return get(key, clazz, NOT_EXPIRE); + } + + public String get(String key, long expire) { + String value = valueOperations.get(key); + if(expire != NOT_EXPIRE){ + redisTemplate.expire(key, expire, TimeUnit.SECONDS); + } + return value; + } + + public String get(String key) { + return get(key, NOT_EXPIRE); + } + + /** SETNX:仅当key不存在时设置值,同时设置过期时间(秒),返回是否设置成功 */ + public boolean setIfAbsent(String key, String value, long expire) { + Boolean result = valueOperations.setIfAbsent(key, value, expire, TimeUnit.SECONDS); + return result != null && result; + } + + public void delete(String key) { + redisTemplate.delete(key); + } + + /** + * SETNX:仅当 key 不存在时设置值,并设置过期时间 + * + * @param key Redis key + * @param value 值 + * @param expire 过期时间(秒) + * @return true=设置成功(key 原先不存在),false=key 已存在 + */ + public boolean setIfAbsent(String key, Object value, long expire) { + if (expire == NOT_EXPIRE) { + return valueOperations.setIfAbsent(key, toJson(value)); + } + return valueOperations.setIfAbsent(key, toJson(value), expire, TimeUnit.SECONDS); + } + + /** + * Object转成JSON数据 + */ + private String toJson(Object object){ + if(object instanceof Integer || object instanceof Long || object instanceof Float || + object instanceof Double || object instanceof Boolean || object instanceof String){ + return String.valueOf(object); + } + return gson.toJson(object); + } + + /** + * JSON数据,转成Object + */ + private T fromJson(String json, Class clazz){ + return gson.fromJson(json, clazz); + } +} diff --git a/src/main/java/io/as/common/utils/RsaUtil.java b/src/main/java/io/as/common/utils/RsaUtil.java new file mode 100755 index 0000000..99a6219 --- /dev/null +++ b/src/main/java/io/as/common/utils/RsaUtil.java @@ -0,0 +1,160 @@ +package io.as.common.utils; + +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import com.google.gson.Gson; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import java.security.*; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.HashMap; +import java.util.UUID; + +/** + * 纯 JDK 实现的 RSA 工具类 + * 不依赖任何第三方库 + */ +public class RsaUtil { + + // 加密算法 + private static final String CIPHER_ALGORITHM = "RSA/ECB/PKCS1Padding"; + // 密钥算法 + private static final String KEY_ALGORITHM = "RSA"; + + /** + * 免登录接口加密demo,并发送 POST 请求 + * @param args + */ + public static void main(String[] args) { + // 生成 timestamp 和 nonce,需与请求 URL 参数保持一致 + String timestamp = String.valueOf(System.currentTimeMillis()); + String nonce = UUID.randomUUID().toString().replace("-", ""); + + HashMap signMap = new HashMap<>(); + signMap.put("accesser", "ASJDTEST"); + signMap.put("businessLine", "4100450011967708592750001"); + signMap.put("uid", "device_77"); + signMap.put("mac", "device_77"); + signMap.put("nonce", nonce); + signMap.put("timestamp", timestamp); + signMap.put("serviceComboCode", "1100121010200492753300170"); + + Gson gson = new Gson(); + String plainData = gson.toJson(signMap); + System.out.println("待加密字符串为:" + plainData); + + String publicKeyStr = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnemyIjwf1S5PsqgPhwiPGcF2Efa4UvFiFpg34+d2ZHQ6mkTTaussXCWQQRZGUfGbvkhIJOspGxmxtaMYgNxMQv9BT5B6xcnMILqYzG40hc2N3EiLm4hDYP12f8pshikh5aNxolbgkdA+Y2vrMf2KNucArMh8gT1mUuQRyUMiefy+N/kk2nK2p6SSX0628/BtczNgj13MaMHMlr8krNTlzB/22M8WMSk10nFS4ULp1KOPia8VxqzMIFKeREPIHIUnarz+llFIj6zKGg0ocqS6KEml7jv1yCgBpmyA2kAGEgnuTreO/5h5DqXByKLBEW8bYDiMUb7H/58IRerrITXfVQIDAQAB"; + String encryptData = encrypt(publicKeyStr, plainData); + System.out.println("加密后的encryptData为:" + encryptData); + + String url = "https://external.future.jetsentv.com/ums_api/api/v1/third/login" + + "?timestamp=" + timestamp + + "&nonce=" + nonce + + "&serviceComboCode=1100121010200492753300170"; + + HttpResponse response = HttpRequest.post(url) + .header("tenant", "huashitv") + .header("encryptData", encryptData) + .header("Cookie", "locale=en-us") + .body("") // 空请求体,与 curl --data '' 一致 + .timeout(10000) + .execute(); + + System.out.println("响应状态码:" + response.getStatus()); + System.out.println("响应内容:" + response.body()); + } + + /** + * 使用公钥加密字符串 + * + * @param key 公钥 + * @param plainText 明文字符串 + * @return 加密后的Base64编码字符串 + */ + public static String encrypt(PublicKey key, String plainText) { + try { + Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, key); + + // 将字符串转换为字节数组 + byte[] plainTextBytes = plainText.getBytes("UTF-8"); + + // 分段加密(RSA有加密数据长度限制) + int inputLen = plainTextBytes.length; + int maxEncryptBlock = 245; // 2048位密钥的最大加密块大小 + + byte[] encryptedData = new byte[0]; + int offset = 0; + + while (inputLen - offset > 0) { + if (inputLen - offset > maxEncryptBlock) { + byte[] encryptedBlock = cipher.doFinal(plainTextBytes, offset, maxEncryptBlock); + encryptedData = concatenateByteArrays(encryptedData, encryptedBlock); + offset += maxEncryptBlock; + } else { + byte[] encryptedBlock = cipher.doFinal(plainTextBytes, offset, inputLen - offset); + encryptedData = concatenateByteArrays(encryptedData, encryptedBlock); + offset = inputLen; + } + } + + // 返回Base64编码的加密数据 + return Base64.getEncoder().encodeToString(encryptedData); + } catch (NoSuchAlgorithmException | NoSuchPaddingException + | InvalidKeyException | IllegalBlockSizeException + | BadPaddingException | java.io.UnsupportedEncodingException e) { + throw new RuntimeException("加密失败", e); + } + } + + /** + * 使用Base64编码的公钥字符串加密 + * + * @param publicKeyStr Base64编码的公钥字符串 + * @param plainText 明文字符串 + * @return 加密后的Base64编码字符串 + */ + public static String encrypt(String publicKeyStr, String plainText) { + try { + PublicKey publicKey = getPublicKeyFromString(publicKeyStr); + return encrypt(publicKey, plainText); + } catch (Exception e) { + throw new RuntimeException("加密失败", e); + } + } + + /** + * 从Base64编码的字符串获取公钥 + * + * @param publicKeyStr Base64编码的公钥字符串 + * @return PublicKey对象 + */ + public static PublicKey getPublicKeyFromString(String publicKeyStr) { + try { + byte[] keyBytes = Base64.getDecoder().decode(publicKeyStr); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); + return keyFactory.generatePublic(keySpec); + } catch (Exception e) { + throw new RuntimeException("获取公钥失败", e); + } + } + + /** + * 合并字节数组 + * + * @param a 第一个字节数组 + * @param b 第二个字节数组 + * @return 合并后的字节数组 + */ + private static byte[] concatenateByteArrays(byte[] a, byte[] b) { + byte[] result = new byte[a.length + b.length]; + System.arraycopy(a, 0, result, 0, a.length); + System.arraycopy(b, 0, result, a.length, b.length); + return result; + } +} \ No newline at end of file diff --git a/src/main/java/io/as/common/utils/ShiroUtils.java b/src/main/java/io/as/common/utils/ShiroUtils.java new file mode 100755 index 0000000..34ee572 --- /dev/null +++ b/src/main/java/io/as/common/utils/ShiroUtils.java @@ -0,0 +1,53 @@ +package io.as.common.utils; + +import io.as.common.exception.ASException; +import io.as.modules.sys.entity.SysUserEntity; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.session.Session; +import org.apache.shiro.subject.Subject; + +/** + * Shiro工具类 + * + * @author Mark sunlightcs@gmail.com + */ +public class ShiroUtils { + + public static Session getSession() { + return SecurityUtils.getSubject().getSession(); + } + + public static Subject getSubject() { + return SecurityUtils.getSubject(); + } + + public static SysUserEntity getUserEntity() { + return (SysUserEntity)SecurityUtils.getSubject().getPrincipal(); + } + + public static Long getUserId() { + return getUserEntity().getId(); + } + + public static void setSessionAttribute(Object key, Object value) { + getSession().setAttribute(key, value); + } + + public static Object getSessionAttribute(Object key) { + return getSession().getAttribute(key); + } + + public static boolean isLogin() { + return SecurityUtils.getSubject().getPrincipal() != null; + } + + public static String getKaptcha(String key) { + Object kaptcha = getSessionAttribute(key); + if(kaptcha == null){ + throw new ASException("验证码已失效"); + } + getSession().removeAttribute(key); + return kaptcha.toString(); + } + +} diff --git a/src/main/java/io/as/common/utils/SpringContextUtils.java b/src/main/java/io/as/common/utils/SpringContextUtils.java new file mode 100755 index 0000000..5978483 --- /dev/null +++ b/src/main/java/io/as/common/utils/SpringContextUtils.java @@ -0,0 +1,47 @@ +package io.as.common.utils; + +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +/** + * Spring Context 工具类 + * + * @author Mark sunlightcs@gmail.com + */ +@Component +public class SpringContextUtils implements ApplicationContextAware { + public static ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) + throws BeansException { + SpringContextUtils.applicationContext = applicationContext; + } + + public static Object getBean(String name) { + return applicationContext.getBean(name); + } + + public static T getBean(String name, Class requiredType) { + return applicationContext.getBean(name, requiredType); + } + + public static T getBean(Class requiredType) { + return applicationContext.getBean(requiredType); + } + + public static boolean containsBean(String name) { + return applicationContext.containsBean(name); + } + + public static boolean isSingleton(String name) { + return applicationContext.isSingleton(name); + } + + public static Class getType(String name) { + return applicationContext.getType(name); + } + +} \ No newline at end of file diff --git a/src/main/java/io/as/common/validator/Assert.java b/src/main/java/io/as/common/validator/Assert.java new file mode 100755 index 0000000..753d87f --- /dev/null +++ b/src/main/java/io/as/common/validator/Assert.java @@ -0,0 +1,24 @@ +package io.as.common.validator; + +import io.as.common.exception.ASException; +import org.apache.commons.lang.StringUtils; + +/** + * 数据校验 + * + * @author Mark sunlightcs@gmail.com + */ +public abstract class Assert { + + public static void isBlank(String str, String message) { + if (StringUtils.isBlank(str)) { + throw new ASException(message); + } + } + + public static void isNull(Object object, String message) { + if (object == null) { + throw new ASException(message); + } + } +} diff --git a/src/main/java/io/as/common/validator/ValidatorUtils.java b/src/main/java/io/as/common/validator/ValidatorUtils.java new file mode 100755 index 0000000..3a44b8f --- /dev/null +++ b/src/main/java/io/as/common/validator/ValidatorUtils.java @@ -0,0 +1,46 @@ +package io.as.common.validator; + +import io.as.common.exception.ASException; +import io.as.common.utils.Constant; + +import javax.validation.ConstraintViolation; +import javax.validation.Validation; +import javax.validation.Validator; +import java.util.Set; + +/** + * hibernate-validator校验工具类 + * + * 参考文档:http://docs.jboss.org/hibernate/validator/5.4/reference/en-US/html_single/ + * + * @author Mark sunlightcs@gmail.com + */ +public class ValidatorUtils { + private static Validator validator; + + static { + validator = Validation.buildDefaultValidatorFactory().getValidator(); + } + + /** + * 校验对象 + * @param object 待校验对象 + * @param groups 待校验的组 + * @throws ASException 校验不通过,则报ASException异常 + */ + public static void validateEntity(Object object, Class... groups) + throws ASException { + Set> constraintViolations = validator.validate(object, groups); + if (!constraintViolations.isEmpty()) { + StringBuilder msg = new StringBuilder(); + for (ConstraintViolation constraint : constraintViolations) { + msg.append(constraint.getMessage()).append("
"); + } + throw new ASException(msg.toString()); + } + } + + public static void validateEntity(Object object, Constant.CloudService type) { + validateEntity(object, type.getValidatorGroupClass()); + } +} diff --git a/src/main/java/io/as/common/validator/group/AddGroup.java b/src/main/java/io/as/common/validator/group/AddGroup.java new file mode 100755 index 0000000..33f28c4 --- /dev/null +++ b/src/main/java/io/as/common/validator/group/AddGroup.java @@ -0,0 +1,9 @@ +package io.as.common.validator.group; + +/** + * 新增数据 Group + * + * @author Mark sunlightcs@gmail.com + */ +public interface AddGroup { +} diff --git a/src/main/java/io/as/common/validator/group/Group.java b/src/main/java/io/as/common/validator/group/Group.java new file mode 100755 index 0000000..30a7874 --- /dev/null +++ b/src/main/java/io/as/common/validator/group/Group.java @@ -0,0 +1,13 @@ +package io.as.common.validator.group; + +import javax.validation.GroupSequence; + +/** + * 定义校验顺序,如果AddGroup组失败,则UpdateGroup组不会再校验 + * + * @author Mark sunlightcs@gmail.com + */ +@GroupSequence({AddGroup.class, UpdateGroup.class}) +public interface Group { + +} diff --git a/src/main/java/io/as/common/validator/group/QcloudGroup.java b/src/main/java/io/as/common/validator/group/QcloudGroup.java new file mode 100755 index 0000000..8393333 --- /dev/null +++ b/src/main/java/io/as/common/validator/group/QcloudGroup.java @@ -0,0 +1,9 @@ +package io.as.common.validator.group; + +/** + * 腾讯云 + * + * @author Mark sunlightcs@gmail.com + */ +public interface QcloudGroup { +} diff --git a/src/main/java/io/as/common/validator/group/UpdateGroup.java b/src/main/java/io/as/common/validator/group/UpdateGroup.java new file mode 100755 index 0000000..9ad043d --- /dev/null +++ b/src/main/java/io/as/common/validator/group/UpdateGroup.java @@ -0,0 +1,11 @@ +package io.as.common.validator.group; + +/** + * 更新数据 Group + * + * @author Mark sunlightcs@gmail.com + */ + +public interface UpdateGroup { + +} diff --git a/src/main/java/io/as/common/xss/HTMLFilter.java b/src/main/java/io/as/common/xss/HTMLFilter.java new file mode 100755 index 0000000..b919fad --- /dev/null +++ b/src/main/java/io/as/common/xss/HTMLFilter.java @@ -0,0 +1,530 @@ +package io.as.common.xss; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * + * HTML filtering utility for protecting against XSS (Cross Site Scripting). + * + * This code is licensed LGPLv3 + * + * This code is a Java port of the original work in PHP by Cal Hendersen. + * http://code.iamcal.com/php/lib_filter/ + * + * The trickiest part of the translation was handling the differences in regex handling + * between PHP and Java. These resources were helpful in the process: + * + * http://java.sun.com/j2se/1.4.2/docs/api/java/util/regex/Pattern.html + * http://us2.php.net/manual/en/reference.pcre.pattern.modifiers.php + * http://www.regular-expressions.info/modifiers.html + * + * A note on naming conventions: instance variables are prefixed with a "v"; global + * constants are in all caps. + * + * Sample use: + * String input = ... + * String clean = new HTMLFilter().filter( input ); + * + * The class is not thread safe. Create a new instance if in doubt. + * + * If you find bugs or have suggestions on improvement (especially regarding + * performance), please contact us. The latest version of this + * source, and our contact details, can be found at http://xss-html-filter.sf.net + * + * @author Joseph O'Connell + * @author Cal Hendersen + * @author Michael Semb Wever + */ +public final class HTMLFilter { + + /** regex flag union representing /si modifiers in php **/ + private static final int REGEX_FLAGS_SI = Pattern.CASE_INSENSITIVE | Pattern.DOTALL; + private static final Pattern P_COMMENTS = Pattern.compile("", Pattern.DOTALL); + private static final Pattern P_COMMENT = Pattern.compile("^!--(.*)--$", REGEX_FLAGS_SI); + private static final Pattern P_TAGS = Pattern.compile("<(.*?)>", Pattern.DOTALL); + private static final Pattern P_END_TAG = Pattern.compile("^/([a-z0-9]+)", REGEX_FLAGS_SI); + private static final Pattern P_START_TAG = Pattern.compile("^([a-z0-9]+)(.*?)(/?)$", REGEX_FLAGS_SI); + private static final Pattern P_QUOTED_ATTRIBUTES = Pattern.compile("([a-z0-9]+)=([\"'])(.*?)\\2", REGEX_FLAGS_SI); + private static final Pattern P_UNQUOTED_ATTRIBUTES = Pattern.compile("([a-z0-9]+)(=)([^\"\\s']+)", REGEX_FLAGS_SI); + private static final Pattern P_PROTOCOL = Pattern.compile("^([^:]+):", REGEX_FLAGS_SI); + private static final Pattern P_ENTITY = Pattern.compile("&#(\\d+);?"); + private static final Pattern P_ENTITY_UNICODE = Pattern.compile("&#x([0-9a-f]+);?"); + private static final Pattern P_ENCODE = Pattern.compile("%([0-9a-f]{2});?"); + private static final Pattern P_VALID_ENTITIES = Pattern.compile("&([^&;]*)(?=(;|&|$))"); + private static final Pattern P_VALID_QUOTES = Pattern.compile("(>|^)([^<]+?)(<|$)", Pattern.DOTALL); + private static final Pattern P_END_ARROW = Pattern.compile("^>"); + private static final Pattern P_BODY_TO_END = Pattern.compile("<([^>]*?)(?=<|$)"); + private static final Pattern P_XML_CONTENT = Pattern.compile("(^|>)([^<]*?)(?=>)"); + private static final Pattern P_STRAY_LEFT_ARROW = Pattern.compile("<([^>]*?)(?=<|$)"); + private static final Pattern P_STRAY_RIGHT_ARROW = Pattern.compile("(^|>)([^<]*?)(?=>)"); + private static final Pattern P_AMP = Pattern.compile("&"); + private static final Pattern P_QUOTE = Pattern.compile("<"); + private static final Pattern P_LEFT_ARROW = Pattern.compile("<"); + private static final Pattern P_RIGHT_ARROW = Pattern.compile(">"); + private static final Pattern P_BOTH_ARROWS = Pattern.compile("<>"); + + // @xxx could grow large... maybe use sesat's ReferenceMap + private static final ConcurrentMap P_REMOVE_PAIR_BLANKS = new ConcurrentHashMap(); + private static final ConcurrentMap P_REMOVE_SELF_BLANKS = new ConcurrentHashMap(); + + /** set of allowed html elements, along with allowed attributes for each element **/ + private final Map> vAllowed; + /** counts of open tags for each (allowable) html element **/ + private final Map vTagCounts = new HashMap(); + + /** html elements which must always be self-closing (e.g. "") **/ + private final String[] vSelfClosingTags; + /** html elements which must always have separate opening and closing tags (e.g. "") **/ + private final String[] vNeedClosingTags; + /** set of disallowed html elements **/ + private final String[] vDisallowed; + /** attributes which should be checked for valid protocols **/ + private final String[] vProtocolAtts; + /** allowed protocols **/ + private final String[] vAllowedProtocols; + /** tags which should be removed if they contain no content (e.g. "" or "") **/ + private final String[] vRemoveBlanks; + /** entities allowed within html markup **/ + private final String[] vAllowedEntities; + /** flag determining whether comments are allowed in input String. */ + private final boolean stripComment; + private final boolean encodeQuotes; + private boolean vDebug = false; + /** + * flag determining whether to try to make tags when presented with "unbalanced" + * angle brackets (e.g. "" becomes " text "). If set to false, + * unbalanced angle brackets will be html escaped. + */ + private final boolean alwaysMakeTags; + + /** Default constructor. + * + */ + public HTMLFilter() { + vAllowed = new HashMap<>(); + + final ArrayList a_atts = new ArrayList(); + a_atts.add("href"); + a_atts.add("target"); + vAllowed.put("a", a_atts); + + final ArrayList img_atts = new ArrayList(); + img_atts.add("src"); + img_atts.add("width"); + img_atts.add("height"); + img_atts.add("alt"); + vAllowed.put("img", img_atts); + + final ArrayList no_atts = new ArrayList(); + vAllowed.put("b", no_atts); + vAllowed.put("strong", no_atts); + vAllowed.put("i", no_atts); + vAllowed.put("em", no_atts); + + vSelfClosingTags = new String[]{"img"}; + vNeedClosingTags = new String[]{"a", "b", "strong", "i", "em"}; + vDisallowed = new String[]{}; + vAllowedProtocols = new String[]{"http", "mailto", "https"}; // no ftp. + vProtocolAtts = new String[]{"src", "href"}; + vRemoveBlanks = new String[]{"a", "b", "strong", "i", "em"}; + vAllowedEntities = new String[]{"amp", "gt", "lt", "quot"}; + stripComment = true; + encodeQuotes = true; + alwaysMakeTags = true; + } + + /** Set debug flag to true. Otherwise use default settings. See the default constructor. + * + * @param debug turn debug on with a true argument + */ + public HTMLFilter(final boolean debug) { + this(); + vDebug = debug; + + } + + /** Map-parameter configurable constructor. + * + * @param conf map containing configuration. keys match field names. + */ + public HTMLFilter(final Map conf) { + + assert conf.containsKey("vAllowed") : "configuration requires vAllowed"; + assert conf.containsKey("vSelfClosingTags") : "configuration requires vSelfClosingTags"; + assert conf.containsKey("vNeedClosingTags") : "configuration requires vNeedClosingTags"; + assert conf.containsKey("vDisallowed") : "configuration requires vDisallowed"; + assert conf.containsKey("vAllowedProtocols") : "configuration requires vAllowedProtocols"; + assert conf.containsKey("vProtocolAtts") : "configuration requires vProtocolAtts"; + assert conf.containsKey("vRemoveBlanks") : "configuration requires vRemoveBlanks"; + assert conf.containsKey("vAllowedEntities") : "configuration requires vAllowedEntities"; + + vAllowed = Collections.unmodifiableMap((HashMap>) conf.get("vAllowed")); + vSelfClosingTags = (String[]) conf.get("vSelfClosingTags"); + vNeedClosingTags = (String[]) conf.get("vNeedClosingTags"); + vDisallowed = (String[]) conf.get("vDisallowed"); + vAllowedProtocols = (String[]) conf.get("vAllowedProtocols"); + vProtocolAtts = (String[]) conf.get("vProtocolAtts"); + vRemoveBlanks = (String[]) conf.get("vRemoveBlanks"); + vAllowedEntities = (String[]) conf.get("vAllowedEntities"); + stripComment = conf.containsKey("stripComment") ? (Boolean) conf.get("stripComment") : true; + encodeQuotes = conf.containsKey("encodeQuotes") ? (Boolean) conf.get("encodeQuotes") : true; + alwaysMakeTags = conf.containsKey("alwaysMakeTags") ? (Boolean) conf.get("alwaysMakeTags") : true; + } + + private void reset() { + vTagCounts.clear(); + } + + private void debug(final String msg) { + if (vDebug) { + Logger.getAnonymousLogger().info(msg); + } + } + + //--------------------------------------------------------------- + // my versions of some PHP library functions + public static String chr(final int decimal) { + return String.valueOf((char) decimal); + } + + public static String htmlSpecialChars(final String s) { + String result = s; + result = regexReplace(P_AMP, "&", result); + result = regexReplace(P_QUOTE, """, result); + result = regexReplace(P_LEFT_ARROW, "<", result); + result = regexReplace(P_RIGHT_ARROW, ">", result); + return result; + } + + //--------------------------------------------------------------- + /** + * given a user submitted input String, filter out any invalid or restricted + * html. + * + * @param input text (i.e. submitted by a user) than may contain html + * @return "clean" version of input, with only valid, whitelisted html elements allowed + */ + public String filter(final String input) { + reset(); + String s = input; + + debug("************************************************"); + debug(" INPUT: " + input); + + s = escapeComments(s); + debug(" escapeComments: " + s); + + s = balanceHTML(s); + debug(" balanceHTML: " + s); + + s = checkTags(s); + debug(" checkTags: " + s); + + s = processRemoveBlanks(s); + debug("processRemoveBlanks: " + s); + + s = validateEntities(s); + debug(" validateEntites: " + s); + + debug("************************************************\n\n"); + return s; + } + + public boolean isAlwaysMakeTags(){ + return alwaysMakeTags; + } + + public boolean isStripComments(){ + return stripComment; + } + + private String escapeComments(final String s) { + final Matcher m = P_COMMENTS.matcher(s); + final StringBuffer buf = new StringBuffer(); + if (m.find()) { + final String match = m.group(1); //(.*?) + m.appendReplacement(buf, Matcher.quoteReplacement("")); + } + m.appendTail(buf); + + return buf.toString(); + } + + private String balanceHTML(String s) { + if (alwaysMakeTags) { + // + // try and form html + // + s = regexReplace(P_END_ARROW, "", s); + s = regexReplace(P_BODY_TO_END, "<$1>", s); + s = regexReplace(P_XML_CONTENT, "$1<$2", s); + + } else { + // + // escape stray brackets + // + s = regexReplace(P_STRAY_LEFT_ARROW, "<$1", s); + s = regexReplace(P_STRAY_RIGHT_ARROW, "$1$2><", s); + + // + // the last regexp causes '<>' entities to appear + // (we need to do a lookahead assertion so that the last bracket can + // be used in the next pass of the regexp) + // + s = regexReplace(P_BOTH_ARROWS, "", s); + } + + return s; + } + + private String checkTags(String s) { + Matcher m = P_TAGS.matcher(s); + + final StringBuffer buf = new StringBuffer(); + while (m.find()) { + String replaceStr = m.group(1); + replaceStr = processTag(replaceStr); + m.appendReplacement(buf, Matcher.quoteReplacement(replaceStr)); + } + m.appendTail(buf); + + s = buf.toString(); + + // these get tallied in processTag + // (remember to reset before subsequent calls to filter method) + for (String key : vTagCounts.keySet()) { + for (int ii = 0; ii < vTagCounts.get(key); ii++) { + s += ""; + } + } + + return s; + } + + private String processRemoveBlanks(final String s) { + String result = s; + for (String tag : vRemoveBlanks) { + if(!P_REMOVE_PAIR_BLANKS.containsKey(tag)){ + P_REMOVE_PAIR_BLANKS.putIfAbsent(tag, Pattern.compile("<" + tag + "(\\s[^>]*)?>")); + } + result = regexReplace(P_REMOVE_PAIR_BLANKS.get(tag), "", result); + if(!P_REMOVE_SELF_BLANKS.containsKey(tag)){ + P_REMOVE_SELF_BLANKS.putIfAbsent(tag, Pattern.compile("<" + tag + "(\\s[^>]*)?/>")); + } + result = regexReplace(P_REMOVE_SELF_BLANKS.get(tag), "", result); + } + + return result; + } + + private static String regexReplace(final Pattern regex_pattern, final String replacement, final String s) { + Matcher m = regex_pattern.matcher(s); + return m.replaceAll(replacement); + } + + private String processTag(final String s) { + // ending tags + Matcher m = P_END_TAG.matcher(s); + if (m.find()) { + final String name = m.group(1).toLowerCase(); + if (allowed(name)) { + if (!inArray(name, vSelfClosingTags)) { + if (vTagCounts.containsKey(name)) { + vTagCounts.put(name, vTagCounts.get(name) - 1); + return ""; + } + } + } + } + + // starting tags + m = P_START_TAG.matcher(s); + if (m.find()) { + final String name = m.group(1).toLowerCase(); + final String body = m.group(2); + String ending = m.group(3); + + //debug( "in a starting tag, name='" + name + "'; body='" + body + "'; ending='" + ending + "'" ); + if (allowed(name)) { + String params = ""; + + final Matcher m2 = P_QUOTED_ATTRIBUTES.matcher(body); + final Matcher m3 = P_UNQUOTED_ATTRIBUTES.matcher(body); + final List paramNames = new ArrayList(); + final List paramValues = new ArrayList(); + while (m2.find()) { + paramNames.add(m2.group(1)); //([a-z0-9]+) + paramValues.add(m2.group(3)); //(.*?) + } + while (m3.find()) { + paramNames.add(m3.group(1)); //([a-z0-9]+) + paramValues.add(m3.group(3)); //([^\"\\s']+) + } + + String paramName, paramValue; + for (int ii = 0; ii < paramNames.size(); ii++) { + paramName = paramNames.get(ii).toLowerCase(); + paramValue = paramValues.get(ii); + +// debug( "paramName='" + paramName + "'" ); +// debug( "paramValue='" + paramValue + "'" ); +// debug( "allowed? " + vAllowed.get( name ).contains( paramName ) ); + + if (allowedAttribute(name, paramName)) { + if (inArray(paramName, vProtocolAtts)) { + paramValue = processParamProtocol(paramValue); + } + params += " " + paramName + "=\"" + paramValue + "\""; + } + } + + if (inArray(name, vSelfClosingTags)) { + ending = " /"; + } + + if (inArray(name, vNeedClosingTags)) { + ending = ""; + } + + if (ending == null || ending.length() < 1) { + if (vTagCounts.containsKey(name)) { + vTagCounts.put(name, vTagCounts.get(name) + 1); + } else { + vTagCounts.put(name, 1); + } + } else { + ending = " /"; + } + return "<" + name + params + ending + ">"; + } else { + return ""; + } + } + + // comments + m = P_COMMENT.matcher(s); + if (!stripComment && m.find()) { + return "<" + m.group() + ">"; + } + + return ""; + } + + private String processParamProtocol(String s) { + s = decodeEntities(s); + final Matcher m = P_PROTOCOL.matcher(s); + if (m.find()) { + final String protocol = m.group(1); + if (!inArray(protocol, vAllowedProtocols)) { + // bad protocol, turn into local anchor link instead + s = "#" + s.substring(protocol.length() + 1, s.length()); + if (s.startsWith("#//")) { + s = "#" + s.substring(3, s.length()); + } + } + } + + return s; + } + + private String decodeEntities(String s) { + StringBuffer buf = new StringBuffer(); + + Matcher m = P_ENTITY.matcher(s); + while (m.find()) { + final String match = m.group(1); + final int decimal = Integer.decode(match).intValue(); + m.appendReplacement(buf, Matcher.quoteReplacement(chr(decimal))); + } + m.appendTail(buf); + s = buf.toString(); + + buf = new StringBuffer(); + m = P_ENTITY_UNICODE.matcher(s); + while (m.find()) { + final String match = m.group(1); + final int decimal = Integer.valueOf(match, 16).intValue(); + m.appendReplacement(buf, Matcher.quoteReplacement(chr(decimal))); + } + m.appendTail(buf); + s = buf.toString(); + + buf = new StringBuffer(); + m = P_ENCODE.matcher(s); + while (m.find()) { + final String match = m.group(1); + final int decimal = Integer.valueOf(match, 16).intValue(); + m.appendReplacement(buf, Matcher.quoteReplacement(chr(decimal))); + } + m.appendTail(buf); + s = buf.toString(); + + s = validateEntities(s); + return s; + } + + private String validateEntities(final String s) { + StringBuffer buf = new StringBuffer(); + + // validate entities throughout the string + Matcher m = P_VALID_ENTITIES.matcher(s); + while (m.find()) { + final String one = m.group(1); //([^&;]*) + final String two = m.group(2); //(?=(;|&|$)) + m.appendReplacement(buf, Matcher.quoteReplacement(checkEntity(one, two))); + } + m.appendTail(buf); + + return encodeQuotes(buf.toString()); + } + + private String encodeQuotes(final String s){ + if(encodeQuotes){ + StringBuffer buf = new StringBuffer(); + Matcher m = P_VALID_QUOTES.matcher(s); + while (m.find()) { + final String one = m.group(1); //(>|^) + final String two = m.group(2); //([^<]+?) + final String three = m.group(3); //(<|$) + m.appendReplacement(buf, Matcher.quoteReplacement(one + regexReplace(P_QUOTE, """, two) + three)); + } + m.appendTail(buf); + return buf.toString(); + }else{ + return s; + } + } + + private String checkEntity(final String preamble, final String term) { + + return ";".equals(term) && isValidEntity(preamble) + ? '&' + preamble + : "&" + preamble; + } + + private boolean isValidEntity(final String entity) { + return inArray(entity, vAllowedEntities); + } + + private static boolean inArray(final String s, final String[] array) { + for (String item : array) { + if (item != null && item.equals(s)) { + return true; + } + } + return false; + } + + private boolean allowed(final String name) { + return (vAllowed.isEmpty() || vAllowed.containsKey(name)) && !inArray(name, vDisallowed); + } + + private boolean allowedAttribute(final String name, final String paramName) { + return allowed(name) && (vAllowed.isEmpty() || vAllowed.get(name).contains(paramName)); + } +} \ No newline at end of file diff --git a/src/main/java/io/as/common/xss/SQLFilter.java b/src/main/java/io/as/common/xss/SQLFilter.java new file mode 100755 index 0000000..9a35b9a --- /dev/null +++ b/src/main/java/io/as/common/xss/SQLFilter.java @@ -0,0 +1,42 @@ +package io.as.common.xss; + +import io.as.common.exception.ASException; +import org.apache.commons.lang.StringUtils; + +/** + * SQL过滤 + * + * @author Mark sunlightcs@gmail.com + */ +public class SQLFilter { + + /** + * SQL注入过滤 + * @param str 待验证的字符串 + */ + public static String sqlInject(String str){ + if(StringUtils.isBlank(str)){ + return null; + } + //去掉'|"|;|\字符 + str = StringUtils.replace(str, "'", ""); + str = StringUtils.replace(str, "\"", ""); + str = StringUtils.replace(str, ";", ""); + str = StringUtils.replace(str, "\\", ""); + + //转换成小写 + str = str.toLowerCase(); + + //非法字符 + String[] keywords = {"master", "truncate", "insert", "select", "delete", "update", "declare", "alter", "drop"}; + + //判断是否包含非法字符 + for(String keyword : keywords){ + if(str.indexOf(keyword) != -1){ + throw new ASException("包含非法字符"); + } + } + + return str; + } +} diff --git a/src/main/java/io/as/common/xss/XssFilter.java b/src/main/java/io/as/common/xss/XssFilter.java new file mode 100755 index 0000000..b829288 --- /dev/null +++ b/src/main/java/io/as/common/xss/XssFilter.java @@ -0,0 +1,37 @@ +package io.as.common.xss; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; + +/** + * XSS过滤 + * + * @author Mark sunlightcs@gmail.com + */ +public class XssFilter implements Filter { + + @Override + public void init(FilterConfig config) throws ServletException { + } + + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest httpRequest = (HttpServletRequest) request; + // /api 需原样读 body 入 sys_api_log(ContentCachingRequestWrapper);Xss 对 JSON 会先读满内层流再换流,易导致下游 body 缓存为空,故 API 不走 XSS 包装 + String path = httpRequest.getRequestURI(); + String ctx = httpRequest.getContextPath() == null ? "" : httpRequest.getContextPath(); + String apiBase = ctx + "/api"; + if (path != null && (path.equals(apiBase) || path.startsWith(apiBase + "/"))) { + chain.doFilter(request, response); + return; + } + XssHttpServletRequestWrapper xssRequest = new XssHttpServletRequestWrapper(httpRequest); + chain.doFilter(xssRequest, response); + } + + @Override + public void destroy() { + } + +} \ No newline at end of file diff --git a/src/main/java/io/as/common/xss/XssHttpServletRequestWrapper.java b/src/main/java/io/as/common/xss/XssHttpServletRequestWrapper.java new file mode 100755 index 0000000..c1af404 --- /dev/null +++ b/src/main/java/io/as/common/xss/XssHttpServletRequestWrapper.java @@ -0,0 +1,139 @@ +package io.as.common.xss; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.StringUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +import javax.servlet.ReadListener; +import javax.servlet.ServletInputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * XSS过滤处理 + * + * @author Mark sunlightcs@gmail.com + */ +public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { + //没被包装过的HttpServletRequest(特殊场景,需要自己过滤) + HttpServletRequest orgRequest; + //html过滤 + private final static HTMLFilter htmlFilter = new HTMLFilter(); + + public XssHttpServletRequestWrapper(HttpServletRequest request) { + super(request); + orgRequest = request; + } + + @Override + public ServletInputStream getInputStream() throws IOException { + //非json类型,直接返回 + if(!MediaType.APPLICATION_JSON_VALUE.equalsIgnoreCase(super.getHeader(HttpHeaders.CONTENT_TYPE))){ + return super.getInputStream(); + } + + //为空,直接返回 + String json = IOUtils.toString(super.getInputStream(), "utf-8"); + if (StringUtils.isBlank(json)) { + return super.getInputStream(); + } + + //xss过滤 + json = xssEncode(json); + final ByteArrayInputStream bis = new ByteArrayInputStream(json.getBytes("utf-8")); + return new ServletInputStream() { + @Override + public boolean isFinished() { + return true; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + + } + + @Override + public int read() throws IOException { + return bis.read(); + } + }; + } + + @Override + public String getParameter(String name) { + String value = super.getParameter(xssEncode(name)); + if (StringUtils.isNotBlank(value)) { + value = xssEncode(value); + } + return value; + } + + @Override + public String[] getParameterValues(String name) { + String[] parameters = super.getParameterValues(name); + if (parameters == null || parameters.length == 0) { + return null; + } + + for (int i = 0; i < parameters.length; i++) { + parameters[i] = xssEncode(parameters[i]); + } + return parameters; + } + + @Override + public Map getParameterMap() { + Map map = new LinkedHashMap<>(); + Map parameters = super.getParameterMap(); + for (String key : parameters.keySet()) { + String[] values = parameters.get(key); + for (int i = 0; i < values.length; i++) { + values[i] = xssEncode(values[i]); + } + map.put(key, values); + } + return map; + } + + @Override + public String getHeader(String name) { + String value = super.getHeader(xssEncode(name)); + if (StringUtils.isNotBlank(value)) { + value = xssEncode(value); + } + return value; + } + + private String xssEncode(String input) { + return htmlFilter.filter(input); + } + + /** + * 获取最原始的request + */ + public HttpServletRequest getOrgRequest() { + return orgRequest; + } + + /** + * 获取最原始的request + */ + public static HttpServletRequest getOrgRequest(HttpServletRequest request) { + if (request instanceof XssHttpServletRequestWrapper) { + return ((XssHttpServletRequestWrapper) request).getOrgRequest(); + } + + return request; + } + +} diff --git a/src/main/java/io/as/config/CorsConfig.java b/src/main/java/io/as/config/CorsConfig.java new file mode 100755 index 0000000..c0e8c9c --- /dev/null +++ b/src/main/java/io/as/config/CorsConfig.java @@ -0,0 +1,36 @@ +package io.as.config; + +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 跨域配置:使用自定义 SimpleCorsFilter 在 Shiro 之前处理 OPTIONS 并回显 Origin(带凭证时不能用 *)。 + */ +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedHeaders("*") + .allowCredentials(false) // 仅用 Authorization,不涉及 Cookie + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") + .maxAge(3600); + } + + /** + * 自定义 CORS 过滤器,最高优先级:OPTIONS 直接 200 + CORS 头,其它请求回显 Origin 以支持凭证。 + */ + @Bean + public FilterRegistrationBean corsFilterRegistration() { + FilterRegistrationBean registration = new FilterRegistrationBean<>(new SimpleCorsFilter()); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE); + registration.addUrlPatterns("/*"); + return registration; + } +} \ No newline at end of file diff --git a/src/main/java/io/as/config/FilterConfig.java b/src/main/java/io/as/config/FilterConfig.java new file mode 100755 index 0000000..974481b --- /dev/null +++ b/src/main/java/io/as/config/FilterConfig.java @@ -0,0 +1,41 @@ +package io.as.config; + +import io.as.common.xss.XssFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.filter.DelegatingFilterProxy; + +import javax.servlet.DispatcherType; + +/** + * Filter配置 + * + * @author Mark sunlightcs@gmail.com + */ +@Configuration +public class FilterConfig { + + @Bean + public FilterRegistrationBean shiroFilterRegistration() { + FilterRegistrationBean registration = new FilterRegistrationBean(); + registration.setFilter(new DelegatingFilterProxy("shiroFilter")); + //该值缺省为false,表示生命周期由SpringApplicationContext管理,设置为true则表示由ServletContainer管理 + registration.addInitParameter("targetFilterLifecycle", "true"); + registration.setEnabled(true); + registration.setOrder(Integer.MAX_VALUE - 1); + registration.addUrlPatterns("/*"); + return registration; + } + + @Bean + public FilterRegistrationBean xssFilterRegistration() { + FilterRegistrationBean registration = new FilterRegistrationBean(); + registration.setDispatcherTypes(DispatcherType.REQUEST); + registration.setFilter(new XssFilter()); + registration.addUrlPatterns("/*"); + registration.setName("xssFilter"); + registration.setOrder(Integer.MAX_VALUE); + return registration; + } +} diff --git a/src/main/java/io/as/config/KaptchaConfig.java b/src/main/java/io/as/config/KaptchaConfig.java new file mode 100644 index 0000000..b9a5e8c --- /dev/null +++ b/src/main/java/io/as/config/KaptchaConfig.java @@ -0,0 +1,25 @@ +package io.as.config; + +import com.google.code.kaptcha.impl.DefaultKaptcha; +import com.google.code.kaptcha.util.Config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Properties; + +@Configuration +public class KaptchaConfig { + + @Bean + public DefaultKaptcha producer() { + Properties properties = new Properties(); + properties.put("kaptcha.border", "no"); + properties.put("kaptcha.textproducer.font.color", "black"); + properties.put("kaptcha.textproducer.char.space", "5"); + properties.put("kaptcha.textproducer.font.names", "Arial,Courier,cmr10,宋体,楷体,微软雅黑"); + Config config = new Config(properties); + DefaultKaptcha defaultKaptcha = new DefaultKaptcha(); + defaultKaptcha.setConfig(config); + return defaultKaptcha; + } +} diff --git a/src/main/java/io/as/config/MybatisPlusConfig.java b/src/main/java/io/as/config/MybatisPlusConfig.java new file mode 100755 index 0000000..de1941c --- /dev/null +++ b/src/main/java/io/as/config/MybatisPlusConfig.java @@ -0,0 +1,23 @@ +package io.as.config; + +import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * mybatis-plus配置 + * + * @author Mark sunlightcs@gmail.com + */ +@Configuration +public class MybatisPlusConfig { + + /** + * 分页插件 + */ + @Bean + public PaginationInterceptor paginationInterceptor() { + return new PaginationInterceptor(); + } + +} diff --git a/src/main/java/io/as/config/RedisConfig.java b/src/main/java/io/as/config/RedisConfig.java new file mode 100755 index 0000000..f8a3d83 --- /dev/null +++ b/src/main/java/io/as/config/RedisConfig.java @@ -0,0 +1,55 @@ +package io.as.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.*; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis配置 + * + * @author Mark sunlightcs@gmail.com + */ +@Configuration +public class RedisConfig { + @Autowired + private RedisConnectionFactory factory; + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setConnectionFactory(factory); + return redisTemplate; + } + + @Bean + public HashOperations hashOperations(RedisTemplate redisTemplate) { + return redisTemplate.opsForHash(); + } + + @Bean + public ValueOperations valueOperations(RedisTemplate redisTemplate) { + return redisTemplate.opsForValue(); + } + + @Bean + public ListOperations listOperations(RedisTemplate redisTemplate) { + return redisTemplate.opsForList(); + } + + @Bean + public SetOperations setOperations(RedisTemplate redisTemplate) { + return redisTemplate.opsForSet(); + } + + @Bean + public ZSetOperations zSetOperations(RedisTemplate redisTemplate) { + return redisTemplate.opsForZSet(); + } +} diff --git a/src/main/java/io/as/config/ShiroConfig.java b/src/main/java/io/as/config/ShiroConfig.java new file mode 100755 index 0000000..0a6d9c0 --- /dev/null +++ b/src/main/java/io/as/config/ShiroConfig.java @@ -0,0 +1,74 @@ +package io.as.config; + +import io.as.modules.sys.jwt.JWTFilter; +import io.as.modules.sys.jwt.JWTRealm; +import org.apache.shiro.mgt.SecurityManager; +import org.apache.shiro.spring.LifecycleBeanPostProcessor; +import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor; +import org.apache.shiro.spring.web.ShiroFilterFactoryBean; +import org.apache.shiro.web.mgt.DefaultWebSecurityManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.servlet.Filter; +import java.util.LinkedHashMap; +import java.util.Map; + +@Configuration +public class ShiroConfig { + + @Bean("securityManager") + public SecurityManager securityManager() { + DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); + securityManager.setRealm(jwtRealm()); + securityManager.setRememberMeManager(null); + return securityManager; + } + + @Bean + public JWTRealm jwtRealm() { + return new JWTRealm(); + } + + @Bean("shiroFilter") + public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) { + ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean(); + shiroFilter.setSecurityManager(securityManager); + + Map filters = new LinkedHashMap<>(); + filters.put("jwt", new JWTFilter()); + shiroFilter.setFilters(filters); + + Map filterMap = new LinkedHashMap<>(); + // 免认证路径 + filterMap.put("/webjars/**", "anon"); + filterMap.put("/druid/**", "anon"); + filterMap.put("/sys/login", "anon"); + filterMap.put("/sys/refresh", "anon"); + filterMap.put("/captcha.jpg", "anon"); + filterMap.put("/openapi/**", "anon"); + filterMap.put("/api/jst/**", "anon"); + filterMap.put("/api/payment/**", "anon"); + filterMap.put("/swagger/**", "anon"); + filterMap.put("/v2/api-docs", "anon"); + filterMap.put("/swagger-ui.html", "anon"); + filterMap.put("/swagger-resources/**", "anon"); + // 其他路径需要 JWT 认证 + filterMap.put("/**", "jwt"); + shiroFilter.setFilterChainDefinitionMap(filterMap); + + return shiroFilter; + } + + @Bean("lifecycleBeanPostProcessor") + public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() { + return new LifecycleBeanPostProcessor(); + } + + @Bean + public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { + AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor(); + advisor.setSecurityManager(securityManager); + return advisor; + } +} diff --git a/src/main/java/io/as/config/SimpleCorsFilter.java b/src/main/java/io/as/config/SimpleCorsFilter.java new file mode 100755 index 0000000..352148f --- /dev/null +++ b/src/main/java/io/as/config/SimpleCorsFilter.java @@ -0,0 +1,49 @@ +package io.as.config; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 跨域过滤器:在 Shiro 之前执行,对 OPTIONS 预检直接返回 200 并设置 CORS 头。 + * 带凭证(cookie/token)时须回显请求的 Origin,不能使用 *,否则浏览器会拒绝。 + */ +public class SimpleCorsFilter implements Filter { + + private static final String ORIGIN = "Origin"; + private static final String AC_ALLOW_ORIGIN = "Access-Control-Allow-Origin"; + private static final String AC_ALLOW_CREDENTIALS = "Access-Control-Allow-Credentials"; + private static final String AC_ALLOW_METHODS = "Access-Control-Allow-Methods"; + private static final String AC_ALLOW_HEADERS = "Access-Control-Allow-Headers"; + private static final String AC_MAX_AGE = "Access-Control-Max-Age"; + private static final String AC_EXPOSE_HEADERS = "Access-Control-Expose-Headers"; + + private static final String ALLOW_METHODS = "GET, POST, PUT, DELETE, OPTIONS, PATCH"; + private static final String ALLOW_HEADERS = "*"; + private static final String MAX_AGE = "3600"; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse resp = (HttpServletResponse) response; + + String origin = req.getHeader(ORIGIN); + if (origin != null && !origin.isEmpty()) { + resp.setHeader(AC_ALLOW_ORIGIN, origin); + resp.setHeader(AC_ALLOW_CREDENTIALS, "false"); // 仅用 Authorization,不涉及 Cookie + } + resp.setHeader(AC_ALLOW_METHODS, ALLOW_METHODS); + resp.setHeader(AC_ALLOW_HEADERS, ALLOW_HEADERS); + resp.setHeader(AC_MAX_AGE, MAX_AGE); + resp.setHeader(AC_EXPOSE_HEADERS, ALLOW_HEADERS); + + if ("OPTIONS".equalsIgnoreCase(req.getMethod())) { + resp.setStatus(HttpServletResponse.SC_OK); + return; + } + + chain.doFilter(request, response); + } +} diff --git a/src/main/java/io/as/config/SwaggerConfig.java b/src/main/java/io/as/config/SwaggerConfig.java new file mode 100755 index 0000000..5104bb5 --- /dev/null +++ b/src/main/java/io/as/config/SwaggerConfig.java @@ -0,0 +1,53 @@ +package io.as.config; + +import io.swagger.annotations.ApiOperation; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import springfox.documentation.builders.ApiInfoBuilder; +import springfox.documentation.builders.PathSelectors; +import springfox.documentation.builders.RequestHandlerSelectors; +import springfox.documentation.service.ApiInfo; +import springfox.documentation.service.ApiKey; +import springfox.documentation.spi.DocumentationType; +import springfox.documentation.spring.web.plugins.Docket; +import springfox.documentation.swagger2.annotations.EnableSwagger2; + +import java.util.List; + +import static com.google.common.collect.Lists.newArrayList; + +@Configuration +@EnableSwagger2 +public class SwaggerConfig implements WebMvcConfigurer { + + @Bean + public Docket createRestApi() { + return new Docket(DocumentationType.SWAGGER_2) + .apiInfo(apiInfo()) + .select() + //加了ApiOperation注解的类,才生成接口文档 + .apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class)) + //包下的类,才生成接口文档 + //.apis(RequestHandlerSelectors.basePackage("io.as.controller")) + .paths(PathSelectors.any()) + .build() + .securitySchemes(security()); + } + + private ApiInfo apiInfo() { + return new ApiInfoBuilder() + .title("爱视文化科技") + .description("爱视后台文档") + .termsOfServiceUrl("https://www.renren.io") + .version("3.0.0") + .build(); + } + + private List security() { + return newArrayList( + new ApiKey("Authorization", "Authorization", "header") + ); + } + +} \ No newline at end of file diff --git a/src/main/java/io/as/datasource/annotation/DataSource.java b/src/main/java/io/as/datasource/annotation/DataSource.java new file mode 100755 index 0000000..c16d1db --- /dev/null +++ b/src/main/java/io/as/datasource/annotation/DataSource.java @@ -0,0 +1,16 @@ +package io.as.datasource.annotation; + +import java.lang.annotation.*; + +/** + * 多数据源注解 + * + * @author Mark sunlightcs@gmail.com + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +public @interface DataSource { + String value() default ""; +} diff --git a/src/main/java/io/as/datasource/aspect/DataSourceAspect.java b/src/main/java/io/as/datasource/aspect/DataSourceAspect.java new file mode 100755 index 0000000..02106bd --- /dev/null +++ b/src/main/java/io/as/datasource/aspect/DataSourceAspect.java @@ -0,0 +1,63 @@ +package io.as.datasource.aspect; + + +import io.as.datasource.annotation.DataSource; +import io.as.datasource.config.DynamicContextHolder; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; + +/** + * 多数据源,切面处理类 + * + * @author Mark sunlightcs@gmail.com + */ +@Aspect +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class DataSourceAspect { + protected Logger logger = LoggerFactory.getLogger(getClass()); + + @Pointcut("@annotation(io.as.datasource.annotation.DataSource) " + + "|| @within(io.as.datasource.annotation.DataSource)") + public void dataSourcePointCut() { + + } + + @Around("dataSourcePointCut()") + public Object around(ProceedingJoinPoint point) throws Throwable { + MethodSignature signature = (MethodSignature) point.getSignature(); + Class targetClass = point.getTarget().getClass(); + Method method = signature.getMethod(); + + DataSource targetDataSource = (DataSource)targetClass.getAnnotation(DataSource.class); + DataSource methodDataSource = method.getAnnotation(DataSource.class); + if(targetDataSource != null || methodDataSource != null){ + String value; + if(methodDataSource != null){ + value = methodDataSource.value(); + }else { + value = targetDataSource.value(); + } + + DynamicContextHolder.push(value); + logger.debug("set datasource is {}", value); + } + + try { + return point.proceed(); + } finally { + DynamicContextHolder.poll(); + logger.debug("clean datasource"); + } + } +} \ No newline at end of file diff --git a/src/main/java/io/as/datasource/config/DynamicContextHolder.java b/src/main/java/io/as/datasource/config/DynamicContextHolder.java new file mode 100755 index 0000000..b81b60d --- /dev/null +++ b/src/main/java/io/as/datasource/config/DynamicContextHolder.java @@ -0,0 +1,49 @@ +package io.as.datasource.config; + +import java.util.ArrayDeque; +import java.util.Deque; + +/** + * 多数据源上下文 + * + * @author Mark sunlightcs@gmail.com + */ +public class DynamicContextHolder { + @SuppressWarnings("unchecked") + private static final ThreadLocal> CONTEXT_HOLDER = new ThreadLocal() { + @Override + protected Object initialValue() { + return new ArrayDeque(); + } + }; + + /** + * 获得当前线程数据源 + * + * @return 数据源名称 + */ + public static String peek() { + return CONTEXT_HOLDER.get().peek(); + } + + /** + * 设置当前线程数据源 + * + * @param dataSource 数据源名称 + */ + public static void push(String dataSource) { + CONTEXT_HOLDER.get().push(dataSource); + } + + /** + * 清空当前线程数据源 + */ + public static void poll() { + Deque deque = CONTEXT_HOLDER.get(); + deque.poll(); + if (deque.isEmpty()) { + CONTEXT_HOLDER.remove(); + } + } + +} \ No newline at end of file diff --git a/src/main/java/io/as/datasource/config/DynamicDataSource.java b/src/main/java/io/as/datasource/config/DynamicDataSource.java new file mode 100755 index 0000000..c1d753b --- /dev/null +++ b/src/main/java/io/as/datasource/config/DynamicDataSource.java @@ -0,0 +1,17 @@ +package io.as.datasource.config; + +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; + +/** + * 多数据源 + * + * @author Mark sunlightcs@gmail.com + */ +public class DynamicDataSource extends AbstractRoutingDataSource { + + @Override + protected Object determineCurrentLookupKey() { + return DynamicContextHolder.peek(); + } + +} diff --git a/src/main/java/io/as/datasource/config/DynamicDataSourceConfig.java b/src/main/java/io/as/datasource/config/DynamicDataSourceConfig.java new file mode 100755 index 0000000..2492832 --- /dev/null +++ b/src/main/java/io/as/datasource/config/DynamicDataSourceConfig.java @@ -0,0 +1,55 @@ +package io.as.datasource.config; + +import com.alibaba.druid.pool.DruidDataSource; +import io.as.datasource.properties.DataSourceProperties; +import io.as.datasource.properties.DynamicDataSourceProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.HashMap; +import java.util.Map; + +/** + * 配置多数据源 + * + * @author Mark sunlightcs@gmail.com + */ +@Configuration +@EnableConfigurationProperties(DynamicDataSourceProperties.class) +public class DynamicDataSourceConfig { + @Autowired + private DynamicDataSourceProperties properties; + + @Bean + @ConfigurationProperties(prefix = "spring.datasource.druid") + public DataSourceProperties dataSourceProperties() { + return new DataSourceProperties(); + } + + @Bean + public DynamicDataSource dynamicDataSource(DataSourceProperties dataSourceProperties) { + DynamicDataSource dynamicDataSource = new DynamicDataSource(); + dynamicDataSource.setTargetDataSources(getDynamicDataSource()); + + //默认数据源 + DruidDataSource defaultDataSource = DynamicDataSourceFactory.buildDruidDataSource(dataSourceProperties); + dynamicDataSource.setDefaultTargetDataSource(defaultDataSource); + + return dynamicDataSource; + } + + private Map getDynamicDataSource(){ + Map dataSourcePropertiesMap = properties.getDatasource(); + Map targetDataSources = new HashMap<>(dataSourcePropertiesMap.size()); + dataSourcePropertiesMap.forEach((k, v) -> { + DruidDataSource druidDataSource = DynamicDataSourceFactory.buildDruidDataSource(v); + targetDataSources.put(k, druidDataSource); + }); + + return targetDataSources; + } + +} \ No newline at end of file diff --git a/src/main/java/io/as/datasource/config/DynamicDataSourceFactory.java b/src/main/java/io/as/datasource/config/DynamicDataSourceFactory.java new file mode 100755 index 0000000..d2e0250 --- /dev/null +++ b/src/main/java/io/as/datasource/config/DynamicDataSourceFactory.java @@ -0,0 +1,46 @@ +package io.as.datasource.config; + +import com.alibaba.druid.pool.DruidDataSource; +import io.as.datasource.properties.DataSourceProperties; + +import java.sql.SQLException; + +/** + * DruidDataSource + * + * @author Mark sunlightcs@gmail.com + * @since 1.0.0 + */ +public class DynamicDataSourceFactory { + + public static DruidDataSource buildDruidDataSource(DataSourceProperties properties) { + DruidDataSource druidDataSource = new DruidDataSource(); + druidDataSource.setDriverClassName(properties.getDriverClassName()); + druidDataSource.setUrl(properties.getUrl()); + druidDataSource.setUsername(properties.getUsername()); + druidDataSource.setPassword(properties.getPassword()); + + druidDataSource.setInitialSize(properties.getInitialSize()); + druidDataSource.setMaxActive(properties.getMaxActive()); + druidDataSource.setMinIdle(properties.getMinIdle()); + druidDataSource.setMaxWait(properties.getMaxWait()); + druidDataSource.setTimeBetweenEvictionRunsMillis(properties.getTimeBetweenEvictionRunsMillis()); + druidDataSource.setMinEvictableIdleTimeMillis(properties.getMinEvictableIdleTimeMillis()); + druidDataSource.setMaxEvictableIdleTimeMillis(properties.getMaxEvictableIdleTimeMillis()); + druidDataSource.setValidationQuery(properties.getValidationQuery()); + druidDataSource.setValidationQueryTimeout(properties.getValidationQueryTimeout()); + druidDataSource.setTestOnBorrow(properties.isTestOnBorrow()); + druidDataSource.setTestOnReturn(properties.isTestOnReturn()); + druidDataSource.setPoolPreparedStatements(properties.isPoolPreparedStatements()); + druidDataSource.setMaxOpenPreparedStatements(properties.getMaxOpenPreparedStatements()); + druidDataSource.setSharePreparedStatements(properties.isSharePreparedStatements()); + + try { + druidDataSource.setFilters(properties.getFilters()); + druidDataSource.init(); + } catch (SQLException e) { + e.printStackTrace(); + } + return druidDataSource; + } +} \ No newline at end of file diff --git a/src/main/java/io/as/datasource/properties/DataSourceProperties.java b/src/main/java/io/as/datasource/properties/DataSourceProperties.java new file mode 100755 index 0000000..037ad57 --- /dev/null +++ b/src/main/java/io/as/datasource/properties/DataSourceProperties.java @@ -0,0 +1,194 @@ +package io.as.datasource.properties; + +/** + * 多数据源属性 + * + * @author Mark sunlightcs@gmail.com + * @since 1.0.0 + */ +public class DataSourceProperties { + private String driverClassName; + private String url; + private String username; + private String password; + + /** + * Druid默认参数 + */ + private int initialSize = 2; + private int maxActive = 10; + private int minIdle = -1; + private long maxWait = 60 * 1000L; + private long timeBetweenEvictionRunsMillis = 60 * 1000L; + private long minEvictableIdleTimeMillis = 1000L * 60L * 30L; + private long maxEvictableIdleTimeMillis = 1000L * 60L * 60L * 7; + private String validationQuery = "select 1"; + private int validationQueryTimeout = -1; + private boolean testOnBorrow = false; + private boolean testOnReturn = false; + private boolean testWhileIdle = true; + private boolean poolPreparedStatements = false; + private int maxOpenPreparedStatements = -1; + private boolean sharePreparedStatements = false; + private String filters = "stat"; + + public String getDriverClassName() { + return driverClassName; + } + + public void setDriverClassName(String driverClassName) { + this.driverClassName = driverClassName; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public int getInitialSize() { + return initialSize; + } + + public void setInitialSize(int initialSize) { + this.initialSize = initialSize; + } + + public int getMaxActive() { + return maxActive; + } + + public void setMaxActive(int maxActive) { + this.maxActive = maxActive; + } + + public int getMinIdle() { + return minIdle; + } + + public void setMinIdle(int minIdle) { + this.minIdle = minIdle; + } + + public long getMaxWait() { + return maxWait; + } + + public void setMaxWait(long maxWait) { + this.maxWait = maxWait; + } + + public long getTimeBetweenEvictionRunsMillis() { + return timeBetweenEvictionRunsMillis; + } + + public void setTimeBetweenEvictionRunsMillis(long timeBetweenEvictionRunsMillis) { + this.timeBetweenEvictionRunsMillis = timeBetweenEvictionRunsMillis; + } + + public long getMinEvictableIdleTimeMillis() { + return minEvictableIdleTimeMillis; + } + + public void setMinEvictableIdleTimeMillis(long minEvictableIdleTimeMillis) { + this.minEvictableIdleTimeMillis = minEvictableIdleTimeMillis; + } + + public long getMaxEvictableIdleTimeMillis() { + return maxEvictableIdleTimeMillis; + } + + public void setMaxEvictableIdleTimeMillis(long maxEvictableIdleTimeMillis) { + this.maxEvictableIdleTimeMillis = maxEvictableIdleTimeMillis; + } + + public String getValidationQuery() { + return validationQuery; + } + + public void setValidationQuery(String validationQuery) { + this.validationQuery = validationQuery; + } + + public int getValidationQueryTimeout() { + return validationQueryTimeout; + } + + public void setValidationQueryTimeout(int validationQueryTimeout) { + this.validationQueryTimeout = validationQueryTimeout; + } + + public boolean isTestOnBorrow() { + return testOnBorrow; + } + + public void setTestOnBorrow(boolean testOnBorrow) { + this.testOnBorrow = testOnBorrow; + } + + public boolean isTestOnReturn() { + return testOnReturn; + } + + public void setTestOnReturn(boolean testOnReturn) { + this.testOnReturn = testOnReturn; + } + + public boolean isTestWhileIdle() { + return testWhileIdle; + } + + public void setTestWhileIdle(boolean testWhileIdle) { + this.testWhileIdle = testWhileIdle; + } + + public boolean isPoolPreparedStatements() { + return poolPreparedStatements; + } + + public void setPoolPreparedStatements(boolean poolPreparedStatements) { + this.poolPreparedStatements = poolPreparedStatements; + } + + public int getMaxOpenPreparedStatements() { + return maxOpenPreparedStatements; + } + + public void setMaxOpenPreparedStatements(int maxOpenPreparedStatements) { + this.maxOpenPreparedStatements = maxOpenPreparedStatements; + } + + public boolean isSharePreparedStatements() { + return sharePreparedStatements; + } + + public void setSharePreparedStatements(boolean sharePreparedStatements) { + this.sharePreparedStatements = sharePreparedStatements; + } + + public String getFilters() { + return filters; + } + + public void setFilters(String filters) { + this.filters = filters; + } +} \ No newline at end of file diff --git a/src/main/java/io/as/datasource/properties/DynamicDataSourceProperties.java b/src/main/java/io/as/datasource/properties/DynamicDataSourceProperties.java new file mode 100755 index 0000000..cb76335 --- /dev/null +++ b/src/main/java/io/as/datasource/properties/DynamicDataSourceProperties.java @@ -0,0 +1,25 @@ +package io.as.datasource.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 多数据源属性 + * + * @author Mark sunlightcs@gmail.com + * @since 1.0.0 + */ +@ConfigurationProperties(prefix = "dynamic") +public class DynamicDataSourceProperties { + private Map datasource = new LinkedHashMap<>(); + + public Map getDatasource() { + return datasource; + } + + public void setDatasource(Map datasource) { + this.datasource = datasource; + } +} diff --git a/src/main/java/io/as/modules/daycard/constants/DayCardConstants.java b/src/main/java/io/as/modules/daycard/constants/DayCardConstants.java new file mode 100644 index 0000000..d64ab23 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/constants/DayCardConstants.java @@ -0,0 +1,166 @@ +package io.as.modules.daycard.constants; + +import java.math.BigDecimal; + +/** + * 天卡业务相关常量 + */ +public final class DayCardConstants { + + private DayCardConstants() { + } + + // ==================== 订单状态 ==================== + /** 已创建 */ + public static final String ORDER_STATUS_CREATED = "PENDING"; + /** 支付中 */ + public static final String ORDER_STATUS_PAYING = "PAYING"; + /** 已支付 */ + public static final String ORDER_STATUS_PAID = "PAID"; + /** 支付失败 */ + public static final String ORDER_STATUS_PAY_FAILED = "PAY_FAILED"; + /** 已取消 */ + public static final String ORDER_STATUS_CANCELLED = "CANCELLED"; + /** 退款中 */ + public static final String ORDER_STATUS_REFUNDING = "REFUNDING"; + + // ==================== 天卡池状态 ==================== + /** 未使用 */ + public static final String POOL_STATUS_UNUSED = "UNUSED"; + /** 已预留 */ + public static final String POOL_STATUS_RESERVED = "RESERVED"; + /** 已使用 */ + public static final String POOL_STATUS_USED = "USED"; + /** 可复用(已解绑,橘视厅权益仍有效) */ + public static final String POOL_STATUS_REUSABLE = "REUSABLE"; + + // ==================== 导入批次状态 ==================== + /** 处理中 */ + public static final String BATCH_STATUS_PROCESSING = "PROCESSING"; + /** 成功 */ + public static final String BATCH_STATUS_SUCCEEDED = "SUCCEEDED"; + /** 部分失败 */ + public static final String BATCH_STATUS_PARTIAL_FAILED = "PARTIAL_FAILED"; + /** 失败 */ + public static final String BATCH_STATUS_FAILED = "FAILED"; + + // ==================== 激活状态 ==================== + /** 待激活 */ + public static final String ACTIVATE_STATUS_PENDING = "PENDING"; + /** 激活成功 */ + public static final String ACTIVATE_STATUS_SUCCESS = "SUCCESS"; + /** 激活失败 */ + public static final String ACTIVATE_STATUS_FAILED = "FAILED"; + + // ==================== 卡类型 ==================== + /** 天卡 */ + public static final String CARD_TYPE_DAY = "DAY"; + + // ==================== Redis Key 前缀 ==================== + /** 设备防重锁前缀 */ + public static final String REDIS_LOCK_PREFIX = "daycard:lock:device:"; + /** 订单前缀 */ + public static final String REDIS_ORDER_PREFIX = "daycard:order:"; + /** 设备订单前缀 */ + public static final String REDIS_DEVICE_ORDER_PREFIX = "daycard:device:"; + /** 代销天卡消耗防重锁前缀(按设备标识加锁) */ + public static final String REDIS_PARTNER_CONSUME_LOCK_PREFIX = "daycard:partner:consume:"; + /** 第三方激活Token缓存前缀(按thirdCardNo缓存) */ + public static final String REDIS_THIRD_PARTY_TOKEN_PREFIX = "api:thirdparty:activate:"; + /** 第三方激活任务锁前缀(按poolId加锁,防止重复激活) */ + public static final String REDIS_THIRD_PARTY_ACTIVATE_LOCK_PREFIX = "api:thirdparty:activate:lock:"; + + // ==================== 删除标记 ==================== + /** 未删除 */ + public static final String DELETE_FLAG_UNDEL = "UNDEL"; + /** 已删除 */ + public static final String DELETE_FLAG_DEL = "DEL"; + + // ==================== 分润规则范围 ==================== + /** 全局 */ + public static final String SCOPE_GLOBAL = "GLOBAL"; + /** 渠道 */ + public static final String SCOPE_CHANNEL = "CHANNEL"; + /** 代理商 */ + public static final String SCOPE_AGENT = "AGENT"; + /** 门店 */ + public static final String SCOPE_STORE = "STORE"; + + // ==================== 分润规则状态 ==================== + /** 启用 */ + public static final String PROFIT_RULE_STATUS_ACTIVE = "ACTIVE"; + /** 禁用 */ + public static final String PROFIT_RULE_STATUS_DISABLED = "DISABLED"; + + // ==================== 分润类型 ==================== + /** 按比例分润 */ + public static final String SHARE_TYPE_RATIO = "RATIO"; + /** 固定金额分润 */ + public static final String SHARE_TYPE_FIXED = "FIXED"; + + // ==================== 分账单状态 ==================== + /** 待处理 */ + public static final String SETTLE_STATUS_PENDING = "PENDING"; + /** 处理中 */ + public static final String SETTLE_STATUS_PROCESSING = "PROCESSING"; + /** 成功 */ + public static final String SETTLE_STATUS_SUCCESS = "SUCCESS"; + /** 失败 */ + public static final String SETTLE_STATUS_FAILED = "FAILED"; + /** 部分成功 */ + public static final String SETTLE_STATUS_PARTIAL = "PARTIAL"; + + // ==================== 接收方绑定状态 ==================== + /** 待绑定 */ + public static final String BIND_STATUS_PENDING = "PENDING"; + /** 已绑定 */ + public static final String BIND_STATUS_ACTIVE = "ACTIVE"; + /** 绑定失败 */ + public static final String BIND_STATUS_FAILED = "FAILED"; + + // ==================== 分账接收方类型 ==================== + /** 商户号 */ + public static final String RECEIVER_TYPE_MERCHANT_ID = "MERCHANT_ID"; + /** 个人OpenID */ + public static final String RECEIVER_TYPE_PERSONAL_OPENID = "PERSONAL_OPENID"; + + // ==================== 组织角色 ==================== + /** 平台 */ + public static final String ORG_ROLE_PLATFORM = "PLATFORM"; + /** 渠道 */ + public static final String ORG_ROLE_CHANNEL = "CHANNEL"; + /** 代理 */ + public static final String ORG_ROLE_AGENT = "AGENT"; + /** 门店 */ + public static final String ORG_ROLE_STORE = "STORE"; + + // ==================== 微信绑定状态 ==================== + /** 已绑定 */ + public static final String WX_BIND_STATUS_BINDED = "BINDED"; + /** 未绑定 */ + public static final String WX_BIND_STATUS_UNBOUND = "UNBOUND"; + + // ==================== 分润比例上限 ==================== + /** 分润比例上限:30% */ + public static final BigDecimal SHARE_RATIO_LIMIT = new BigDecimal("0.3000"); + /** 非平台方分润比例之和上限:30% */ + public static final double PROFIT_SHARE_LIMIT = 30.0; + + // ==================== 退款状态 ==================== + /** 退款待处理 */ + public static final String REFUND_STATUS_PENDING = "PENDING"; + /** 退款处理中 */ + public static final String REFUND_STATUS_PROCESSING = "PROCESSING"; + /** 退款成功 */ + public static final String REFUND_STATUS_SUCCESS = "SUCCESS"; + /** 退款失败 */ + public static final String REFUND_STATUS_FAILED = "FAILED"; + /** 退款关闭 */ + public static final String REFUND_STATUS_CLOSED = "CLOSED"; + + // ==================== 支付渠道 ==================== + /** Mock支付 */ + public static final String PAY_CHANNEL_MOCK = "MOCK"; + /** 微信支付 */ + public static final String PAY_CHANNEL_WECHAT = "WECHAT"; +} diff --git a/src/main/java/io/as/modules/daycard/controller/DayCardActivationCodeController.java b/src/main/java/io/as/modules/daycard/controller/DayCardActivationCodeController.java new file mode 100644 index 0000000..055e47d --- /dev/null +++ b/src/main/java/io/as/modules/daycard/controller/DayCardActivationCodeController.java @@ -0,0 +1,165 @@ +package io.as.modules.daycard.controller; + +import io.as.common.annotation.AuditAction; +import io.as.common.exception.ASException; +import io.as.common.utils.R; +import io.as.modules.daycard.entity.DayCardActivationCodeEntity; +import io.as.modules.daycard.service.DayCardActivationCodeService; +import io.as.modules.sys.entity.SysOrgEntity; +import io.as.modules.sys.service.SysOrgService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 天卡设备激活码管理(管理后台用) + *

管理后台按门店生成激活码,App 端 init 时输入激活码完成设备注册绑定

+ */ +@Api(tags = "天卡设备激活码") +@RestController +@RequestMapping("/manage/daycard/activation-code") +public class DayCardActivationCodeController { + + @Autowired + private DayCardActivationCodeService dayCardActivationCodeService; + + @Autowired + private SysOrgService sysOrgService; + + /** + * 按门店查询激活码列表 + */ + @ApiOperation("按门店查询激活码列表") + @GetMapping("/list/{storeOrgId}") + public R listByStore(@PathVariable("storeOrgId") Long storeOrgId) { + List list = dayCardActivationCodeService.listByStoreOrgId(storeOrgId); + List> result = new ArrayList<>(); + for (DayCardActivationCodeEntity entity : list) { + result.add(toMap(entity)); + } + Map data = new HashMap<>(); + data.put("list", result); + return R.dataSuccess(data); + } + + /** + * 为门店生成激活码(每门店一个,已存在则返回已有的) + */ + @ApiOperation("为门店生成激活码") + @AuditAction(value = "ACTCODE_GENERATE", description = "生成门店激活码") + @PostMapping("/generate") + public R generate(@RequestBody GenerateRequest request) { + if (request.getStoreOrgId() == null) { + throw new ASException("门店组织ID不能为空"); + } + // 校验门店存在 + SysOrgEntity storeOrg = sysOrgService.getById(request.getStoreOrgId()); + if (storeOrg == null) { + throw new ASException("门店不存在"); + } + // 从门店 mpath 解析渠道 + Long channelOrgId = resolveChannelOrgId(storeOrg); + DayCardActivationCodeEntity entity = dayCardActivationCodeService.generateForStore( + request.getStoreOrgId(), channelOrgId, request.getRemark()); + Map data = new HashMap<>(); + data.put("data", toMap(entity)); + return R.dataSuccess(data); + } + + /** + * 重新生成激活码 code(旧 code 失效) + */ + @ApiOperation("重新生成激活码") + @AuditAction(value = "ACTCODE_REGENERATE", description = "重新生成激活码") + @PostMapping("/{id}/regenerate") + public R regenerate(@PathVariable("id") Long id) { + String newCode = dayCardActivationCodeService.regenerateCode(id); + Map item = new HashMap<>(); + item.put("code", newCode); + Map data = new HashMap<>(); + data.put("data", item); + return R.dataSuccess(data); + } + + /** + * 启用/禁用激活码 + */ + @ApiOperation("启用/禁用激活码") + @AuditAction(value = "ACTCODE_UPDATE_STATUS", description = "修改激活码状态") + @PutMapping("/{id}/status") + public R updateStatus(@PathVariable("id") Long id, @RequestBody UpdateStatusRequest request) { + dayCardActivationCodeService.updateStatus(id, request.getStatus()); + return R.ok(); + } + + /** + * 删除激活码(软删除) + */ + @ApiOperation("删除激活码") + @AuditAction(value = "ACTCODE_DELETE", description = "删除激活码") + @DeleteMapping("/{id}") + public R delete(@PathVariable("id") Long id) { + dayCardActivationCodeService.delete(id); + return R.ok(); + } + + // ==================== 内部方法 ==================== + + /** + * 从门店 mpath 解析渠道组织ID(mpath 第2段) + */ + private Long resolveChannelOrgId(SysOrgEntity storeOrg) { + if (storeOrg == null || StringUtils.isBlank(storeOrg.getMpath())) { + return null; + } + String[] segments = storeOrg.getMpath().split("\\."); + if (segments.length >= 2) { + try { + return Long.parseLong(segments[1]); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + private Map toMap(DayCardActivationCodeEntity entity) { + Map m = new HashMap<>(); + m.put("id", entity.getId()); + m.put("code", entity.getCode()); + m.put("channelOrgId", entity.getChannelOrgId()); + m.put("storeOrgId", entity.getStoreOrgId()); + m.put("status", entity.getStatus()); + m.put("maxDeviceCount", entity.getMaxDeviceCount()); + m.put("activatedCount", entity.getActivatedCount()); + m.put("remark", entity.getRemark()); + m.put("createdAt", entity.getCreatedAt()); + return m; + } + + /** + * 生成激活码请求 + */ + @lombok.Data + public static class GenerateRequest { + private Long storeOrgId; + private String remark; + } + + /** + * 更新状态请求 + */ + @lombok.Data + public static class UpdateStatusRequest { + private String status; + } +} diff --git a/src/main/java/io/as/modules/daycard/controller/DayCardManageController.java b/src/main/java/io/as/modules/daycard/controller/DayCardManageController.java new file mode 100644 index 0000000..3000f42 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/controller/DayCardManageController.java @@ -0,0 +1,192 @@ +package io.as.modules.daycard.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import io.as.common.annotation.AuditAction; +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.entity.DayCardPoolEntity; +import io.as.modules.daycard.service.DayCardOrderService; +import io.as.modules.daycard.service.DayCardPoolService; +import io.as.modules.daycard.vo.DayCardPoolStatsVo; +import org.apache.shiro.authz.annotation.RequiresAuthentication; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 天卡管理后台接口(JWT 鉴权) + *

供 Web 管理后台调用,与 /openapi/** 合作伙伴接口隔离

+ */ +@RestController +@RequestMapping("/manage/daycard") +@RequiresAuthentication +public class DayCardManageController { + + @Autowired + private DayCardPoolService dayCardPoolService; + + @Autowired + private DayCardOrderService dayCardOrderService; + + // ===================== 天卡池 ===================== + + /** + * 天卡池列表 + */ + @GetMapping("/pool/list") + public R poolList(@RequestParam Map params) { + Long partnerId = parsePartnerId(params); + PageUtils page = dayCardPoolService.queryPage(params, partnerId); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + /** + * 天卡池统计 + */ + @GetMapping("/pool/stats") + public R poolStats(@RequestParam(required = false) Long partnerId) { + DayCardPoolStatsVo vo = buildStats(partnerId); + Map data = new HashMap<>(); + data.put("stats", vo); + return R.dataSuccess(data); + } + + /** + * 导入天卡卡密 + */ + @AuditAction(value = "DAYCARD_POOL_IMPORT", description = "导入天卡卡密") + @PostMapping("/pool/import") + public R poolImport(@RequestBody Map body) { + Long partnerId = null; + Object pid = body.get("partnerId"); + if (pid != null) { + partnerId = Long.parseLong(String.valueOf(pid)); + } + Object codesObj = body.get("cardCodes"); + if (codesObj == null) { + return R.error("cardCodes 不能为空"); + } + @SuppressWarnings("unchecked") + List cardCodes = (List) codesObj; + if (cardCodes.isEmpty()) { + return R.error("cardCodes 不能为空"); + } + if (partnerId == null) { + return R.error("partnerId 不能为空"); + } + int success = dayCardPoolService.importCards(cardCodes, partnerId); + Map data = new HashMap<>(); + data.put("successCount", success); + data.put("totalCount", cardCodes.size()); + return R.dataSuccess(data); + } + + // ===================== 天卡订单 ===================== + + /** + * 天卡订单列表 + */ + @GetMapping("/order/list") + public R orderList(@RequestParam Map params) { + Long partnerId = parsePartnerId(params); + PageUtils page = dayCardOrderService.queryPage(params, partnerId); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + // ===================== 设备权益 ===================== + + /** + * 查询设备天卡权益 + */ + @GetMapping("/benefits/{deviceId}") + public R deviceBenefits(@PathVariable Long deviceId) { + DayCardPoolEntity pool = dayCardOrderService.getDeviceBenefits(deviceId); + Map item = new HashMap<>(); + if (pool != null) { + item.put("hasBenefits", true); + item.put("thirdCardNo", pool.getThirdCardNo()); + item.put("cardCode", pool.getCardCode()); + item.put("expireAt", pool.getExpireAt()); + } else { + item.put("hasBenefits", false); + } + Map data = new HashMap<>(); + data.put("data", item); + return R.dataSuccess(data); + } + + /** + * 解绑设备天卡权益(Web端手动操作) + *

将设备绑定的 USED 卡转为 REUSABLE,释放设备。橘视厅权益保留,后续新设备下单可复用此卡。

+ */ + @AuditAction(value = "DAYCARD_DEVICE_UNBIND", description = "解绑设备天卡权益") + @PostMapping("/device/unbind") + public R unbindDevice(@RequestParam Long deviceId) { + dayCardOrderService.unbindDevice(deviceId); + return R.ok("设备解绑成功"); + } + + /** + * 设备组织解绑(更换安装场所时调用) + *

释放设备当前天卡权益并清除门店组织关联(storeOrgId),设备可在新场所用新激活码重新激活绑定。

+ */ + @AuditAction(value = "DAYCARD_DEVICE_UNBIND_ORG", description = "设备组织解绑") + @PostMapping("/device/unbind-org") + public R unbindDeviceOrg(@RequestParam Long deviceId) { + dayCardOrderService.unbindDeviceOrg(deviceId); + return R.ok("设备组织解绑成功"); + } + + // ===================== 内部方法 ===================== + + private Long parsePartnerId(Map params) { + Object val = params.get("partnerId"); + if (val == null) { + val = params.get("partner_id"); + } + if (val == null || "".equals(val)) { + return null; + } + try { + return Long.parseLong(String.valueOf(val)); + } catch (NumberFormatException e) { + return null; + } + } + + private DayCardPoolStatsVo buildStats(Long partnerId) { + DayCardPoolStatsVo vo = new DayCardPoolStatsVo(); + + int available = dayCardPoolService.getAvailableCount(partnerId); + vo.setAvailable(available); + + QueryWrapper reservedWrapper = new QueryWrapper<>(); + reservedWrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .eq("status", DayCardConstants.POOL_STATUS_RESERVED); + if (partnerId != null) { + reservedWrapper.eq("partner_id", partnerId); + } + int reserved = dayCardPoolService.count(reservedWrapper); + vo.setReserved(reserved); + + QueryWrapper usedWrapper = new QueryWrapper<>(); + usedWrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .eq("status", DayCardConstants.POOL_STATUS_USED); + if (partnerId != null) { + usedWrapper.eq("partner_id", partnerId); + } + int used = dayCardPoolService.count(usedWrapper); + vo.setUsed(used); + + vo.setTotal(available + reserved + used); + return vo; + } +} diff --git a/src/main/java/io/as/modules/daycard/controller/DayCardOrderController.java b/src/main/java/io/as/modules/daycard/controller/DayCardOrderController.java new file mode 100644 index 0000000..b844bec --- /dev/null +++ b/src/main/java/io/as/modules/daycard/controller/DayCardOrderController.java @@ -0,0 +1,73 @@ +package io.as.modules.daycard.controller; + +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.modules.daycard.entity.DayCardOrderEntity; +import io.as.modules.daycard.service.DayCardOrderService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 天卡订单 Controller(管理后台用) + *

App 端接口已迁移至 /api/jst/** (ApiJstController),本 Controller 仅保留管理后台用的分页查询和详情接口

+ */ +@Api(tags = "天卡订单") +@RestController +@RequestMapping("/manage/daycard") +public class DayCardOrderController { + + @Autowired + private DayCardOrderService dayCardOrderService; + + /** + * 分页查询订单 + */ + @ApiOperation("分页查询天卡订单") + @GetMapping("/list") + public R list(@RequestParam Map params) { + Long partnerId = parsePartnerId(params); + PageUtils page = dayCardOrderService.queryPage(params, partnerId); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + /** + * 查询订单详情 + */ + @ApiOperation("查询订单详情") + @GetMapping("/info/{orderNo}") + public R info(@PathVariable("orderNo") String orderNo) { + DayCardOrderEntity order = dayCardOrderService.getByOrderNo(orderNo); + if (order == null) { + throw new ASException("订单不存在"); + } + Map data = new HashMap<>(); + data.put("order", order); + return R.dataSuccess(data); + } + + /** + * 从请求参数中解析 partnerId(兼容存量数据查询,新订单 partnerId 为 null) + */ + private Long parsePartnerId(Map params) { + Object val = params.get("partnerId"); + if (val == null) { + val = params.get("partner_id"); + } + if (val == null) { + return null; + } + try { + return Long.parseLong(String.valueOf(val)); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/io/as/modules/daycard/controller/DayCardPriceConfigController.java b/src/main/java/io/as/modules/daycard/controller/DayCardPriceConfigController.java new file mode 100644 index 0000000..76c40f4 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/controller/DayCardPriceConfigController.java @@ -0,0 +1,129 @@ +package io.as.modules.daycard.controller; + +import io.as.common.annotation.AuditAction; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.entity.DayCardPriceConfigEntity; +import io.as.modules.daycard.service.DayCardPriceConfigService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 天卡定价策略管理 Controller + */ +@Api(tags = "天卡定价策略管理") +@RestController +@RequestMapping("/manage/price") +public class DayCardPriceConfigController { + + @Autowired + private DayCardPriceConfigService dayCardPriceConfigService; + + /** + * 分页查询定价策略(支持 partnerId/status 筛选) + */ + @ApiOperation("分页查询定价策略") + @GetMapping("/list") + public R list(@RequestParam Map params) { + PageUtils page = dayCardPriceConfigService.queryPage(params); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + /** + * 查询定价策略详情 + */ + @ApiOperation("定价策略详情") + @GetMapping("/info/{id}") + public R info(@PathVariable("id") Long id) { + DayCardPriceConfigEntity config = dayCardPriceConfigService.getById(id); + if (config == null) { + throw new ASException("定价策略不存在"); + } + Map data = new HashMap<>(); + data.put("config", config); + return R.dataSuccess(data); + } + + /** + * 查询生效中的定价策略(3 层维度) + *

优先级:门店配置价 > 渠道配置价 > 平台默认价兜底

+ * + * @param channelOrgId 渠道组织ID(可空) + * @param storeOrgId 门店组织ID(可空) + */ + @ApiOperation("查询生效中的定价策略") + @GetMapping("/effective") + public R effective(@RequestParam(value = "channelOrgId", required = false) Long channelOrgId, + @RequestParam(value = "storeOrgId", required = false) Long storeOrgId) { + DayCardPriceConfigEntity config = dayCardPriceConfigService.getEffectivePrice(channelOrgId, storeOrgId); + Map data = new HashMap<>(); + data.put("config", config); + return R.dataSuccess(data); + } + + /** + * 新增定价策略 + */ + @ApiOperation("新增定价策略") + @AuditAction(value = "PRICE_SAVE", description = "新增定价策略") + @PostMapping("/save") + public R save(@RequestBody DayCardPriceConfigEntity config) { + Date now = new Date(); + config.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL); + config.setCreatedAt(now); + config.setUpdatedAt(now); + if (config.getStatus() == null) { + config.setStatus("ACTIVE"); + } + dayCardPriceConfigService.save(config); + return R.ok(); + } + + /** + * 修改定价策略 + */ + @ApiOperation("修改定价策略") + @AuditAction(value = "PRICE_UPDATE", description = "修改定价策略") + @PostMapping("/update") + public R update(@RequestBody DayCardPriceConfigEntity config) { + if (config.getId() == null) { + throw new ASException("id不能为空"); + } + config.setUpdatedAt(new Date()); + dayCardPriceConfigService.updateById(config); + return R.ok(); + } + + /** + * 软删除定价策略 + */ + @ApiOperation("删除定价策略") + @AuditAction(value = "PRICE_DELETE", description = "删除定价策略") + @PostMapping("/delete") + public R delete(@RequestBody Long[] ids) { + if (ids == null || ids.length == 0) { + throw new ASException("ids不能为空"); + } + List list = dayCardPriceConfigService.listByIds( + Arrays.asList(ids)); + Date now = new Date(); + for (DayCardPriceConfigEntity config : list) { + config.setDeleteFlag(DayCardConstants.DELETE_FLAG_DEL); + config.setUpdatedAt(now); + } + dayCardPriceConfigService.updateBatchById(list); + return R.ok(); + } +} diff --git a/src/main/java/io/as/modules/daycard/controller/DayCardRefundController.java b/src/main/java/io/as/modules/daycard/controller/DayCardRefundController.java new file mode 100644 index 0000000..9e21707 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/controller/DayCardRefundController.java @@ -0,0 +1,75 @@ +package io.as.modules.daycard.controller; + +import io.as.common.annotation.AuditAction; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.modules.daycard.entity.DayCardRefundEntity; +import io.as.modules.daycard.service.DayCardRefundService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 天卡退款管理 Controller + */ +@Api(tags = "天卡退款管理") +@RestController +@RequestMapping("/manage/refund") +public class DayCardRefundController { + + @Autowired + private DayCardRefundService dayCardRefundService; + + /** + * 分页查询退款记录 + */ + @ApiOperation("分页查询退款记录") + @GetMapping("/list") + public R list(@RequestParam Map params) { + PageUtils page = dayCardRefundService.queryPage(params); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + /** + * 退款详情 + */ + @ApiOperation("退款详情") + @GetMapping("/info/{refundNo}") + public R info(@PathVariable("refundNo") String refundNo) { + DayCardRefundEntity refund = dayCardRefundService.getByRefundNo(refundNo); + if (refund == null) { + throw new ASException("退款记录不存在"); + } + Map data = new HashMap<>(); + data.put("refund", refund); + return R.dataSuccess(data); + } + + /** + * 发起退款 + * + * @param params 包含 orderNo(订单号)和 reason(退款原因) + */ + @ApiOperation("发起退款") + @AuditAction(value = "REFUND_APPLY", description = "发起退款") + @PostMapping("/apply") + public R apply(@RequestBody Map params) { + String orderNo = params.get("orderNo"); + if (StringUtils.isBlank(orderNo)) { + throw new ASException("orderNo不能为空"); + } + String reason = params.get("reason"); + DayCardRefundEntity refund = dayCardRefundService.refund(orderNo, reason); + Map data = new HashMap<>(); + data.put("refund", refund); + return R.dataSuccess(data); + } +} diff --git a/src/main/java/io/as/modules/daycard/controller/DayCardSettleController.java b/src/main/java/io/as/modules/daycard/controller/DayCardSettleController.java new file mode 100644 index 0000000..b83ee64 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/controller/DayCardSettleController.java @@ -0,0 +1,192 @@ +package io.as.modules.daycard.controller; + +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.modules.daycard.entity.DayCardSettleOrderEntity; +import io.as.modules.daycard.entity.DayCardSettleRecordEntity; +import io.as.modules.daycard.entity.DayCardSettleRuleEntity; +import io.as.modules.daycard.service.DayCardSettleOrderService; +import io.as.modules.daycard.service.DayCardSettleRecordService; +import io.as.modules.daycard.service.DayCardSettleRuleService; +import io.as.modules.daycard.service.ProfitCalcService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; + +/** + * 天卡分账管理 Controller + */ +@Api(tags = "天卡分账管理") +@RestController +@RequestMapping("/manage/settle") +public class DayCardSettleController { + + @Autowired + private DayCardSettleRuleService dayCardSettleRuleService; + + @Autowired + private DayCardSettleRecordService dayCardSettleRecordService; + + @Autowired + private DayCardSettleOrderService dayCardSettleOrderService; + + @Autowired + private ProfitCalcService profitCalcService; + + // ==================== 分账规则 ==================== + + /** + * 分页查询分账规则 + */ + @ApiOperation("分页查询分账规则") + @GetMapping("/rule/list") + public R ruleList(@RequestParam Map params) { + PageUtils page = dayCardSettleRuleService.queryPage(params); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + /** + * 分账规则详情 + */ + @ApiOperation("分账规则详情") + @GetMapping("/rule/info/{id}") + public R ruleInfo(@PathVariable("id") Long id) { + DayCardSettleRuleEntity rule = dayCardSettleRuleService.getById(id); + if (rule == null) { + throw new ASException("分账规则不存在"); + } + Map data = new HashMap<>(); + data.put("rule", rule); + return R.dataSuccess(data); + } + + /** + * 新增分账规则 + */ + @ApiOperation("新增分账规则") + @PostMapping("/rule/save") + public R ruleSave(@RequestBody DayCardSettleRuleEntity rule) { + if (rule.getName() == null || rule.getName().trim().isEmpty()) { + throw new ASException("规则名称不能为空"); + } + if (rule.getShareRatio() == null) { + throw new ASException("分账比例不能为空"); + } + dayCardSettleRuleService.saveRule(rule); + Map data = new HashMap<>(); + data.put("rule", rule); + return R.dataSuccess(data); + } + + /** + * 修改分账规则 + */ + @ApiOperation("修改分账规则") + @PostMapping("/rule/update") + public R ruleUpdate(@RequestBody DayCardSettleRuleEntity rule) { + if (rule.getId() == null) { + throw new ASException("id不能为空"); + } + dayCardSettleRuleService.update(rule); + return R.ok(); + } + + /** + * 删除分账规则(软删除) + */ + @ApiOperation("删除分账规则") + @PostMapping("/rule/delete") + public R ruleDelete(@RequestBody Long[] ids) { + dayCardSettleRuleService.delete(ids); + return R.ok(); + } + + // ==================== 分账记录 ==================== + + /** + * 分页查询分账记录 + */ + @ApiOperation("分页查询分账记录") + @GetMapping("/record/list") + public R recordList(@RequestParam Map params) { + PageUtils page = dayCardSettleRecordService.queryPage(params); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + /** + * 分账记录详情 + */ + @ApiOperation("分账记录详情") + @GetMapping("/record/info/{id}") + public R recordInfo(@PathVariable("id") Long id) { + DayCardSettleRecordEntity record = dayCardSettleRecordService.getById(id); + if (record == null) { + throw new ASException("分账记录不存在"); + } + Map data = new HashMap<>(); + data.put("record", record); + return R.dataSuccess(data); + } + + // ==================== 分账单 ==================== + + /** + * 分页查询分账单 + */ + @ApiOperation("分页查询分账单") + @GetMapping("/order/list") + public R orderList(@RequestParam Map params) { + PageUtils page = dayCardSettleOrderService.queryPage(params); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + /** + * 分账单详情 + */ + @ApiOperation("分账单详情") + @GetMapping("/order/info/{id}") + public R orderInfo(@PathVariable("id") Long id) { + DayCardSettleOrderEntity settleOrder = dayCardSettleOrderService.getById(id); + if (settleOrder == null) { + throw new ASException("分账单不存在"); + } + Map data = new HashMap<>(); + data.put("settleOrder", settleOrder); + return R.dataSuccess(data); + } + + // ==================== 试算 ==================== + + /** + * 分润试算 + */ + @ApiOperation("分润试算") + @GetMapping("/trial") + public R trial(@RequestParam("partnerId") Long partnerId, + @RequestParam(value = "channelOrgId", required = false) Long channelOrgId, + @RequestParam(value = "storeOrgId", required = false) Long storeOrgId, + @RequestParam("totalAmount") BigDecimal totalAmount) { + if (partnerId == null) { + throw new ASException("partnerId不能为空"); + } + if (totalAmount == null || totalAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw new ASException("totalAmount必须大于0"); + } + Map result = profitCalcService.calculate(partnerId, channelOrgId, storeOrgId, totalAmount); + Map data = new HashMap<>(); + data.put("data", result); + return R.dataSuccess(data); + } +} diff --git a/src/main/java/io/as/modules/daycard/controller/OrgSettlementAccountController.java b/src/main/java/io/as/modules/daycard/controller/OrgSettlementAccountController.java new file mode 100644 index 0000000..fb093cf --- /dev/null +++ b/src/main/java/io/as/modules/daycard/controller/OrgSettlementAccountController.java @@ -0,0 +1,98 @@ +package io.as.modules.daycard.controller; + +import io.as.common.annotation.AuditAction; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.modules.daycard.entity.OrgSettlementAccountEntity; +import io.as.modules.daycard.service.OrgSettlementAccountService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 组织分账接收方 Controller + */ +@Api(tags = "组织分账接收方管理") +@RestController +@RequestMapping("/manage/profit-receiver") +public class OrgSettlementAccountController { + + @Autowired + private OrgSettlementAccountService orgSettlementAccountService; + + /** + * 分页查询分账接收方 + */ + @ApiOperation("分页查询分账接收方") + @GetMapping("/list") + public R list(@RequestParam Map params) { + PageUtils page = orgSettlementAccountService.queryPage(params); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + /** + * 分账接收方详情 + */ + @ApiOperation("分账接收方详情") + @GetMapping("/info/{id}") + public R info(@PathVariable("id") Long id) { + OrgSettlementAccountEntity account = orgSettlementAccountService.getById(id); + if (account == null) { + throw new ASException("分账接收方不存在"); + } + Map data = new HashMap<>(); + data.put("account", account); + return R.dataSuccess(data); + } + + /** + * 新增分账接收方 + */ + @ApiOperation("新增分账接收方") + @AuditAction(value = "PROFIT_RECEIVER_SAVE", description = "新增分账接收方") + @PostMapping("/save") + public R save(@RequestBody OrgSettlementAccountEntity account) { + if (account.getOrgId() == null) { + throw new ASException("组织ID不能为空"); + } + if (account.getReceiverAccount() == null || account.getReceiverAccount().trim().isEmpty()) { + throw new ASException("接收方账号不能为空"); + } + if (account.getReceiverType() == null || account.getReceiverType().trim().isEmpty()) { + throw new ASException("接收方类型不能为空"); + } + orgSettlementAccountService.saveAccount(account); + Map data = new HashMap<>(); + data.put("account", account); + return R.dataSuccess(data); + } + + /** + * 删除分账接收方(软删除) + */ + @ApiOperation("删除分账接收方") + @AuditAction(value = "PROFIT_RECEIVER_DELETE", description = "删除分账接收方") + @PostMapping("/delete") + public R delete(@RequestBody Long[] ids) { + orgSettlementAccountService.delete(ids); + return R.ok(); + } + + /** + * 同步接收方到微信 + */ + @ApiOperation("同步接收方到微信") + @AuditAction(value = "PROFIT_RECEIVER_SYNC", description = "同步分账接收方到微信") + @PostMapping("/sync/{id}") + public R sync(@PathVariable("id") Long id) { + orgSettlementAccountService.syncToWechat(id); + return R.ok(); + } +} diff --git a/src/main/java/io/as/modules/daycard/controller/ProfitSharingQueryController.java b/src/main/java/io/as/modules/daycard/controller/ProfitSharingQueryController.java new file mode 100644 index 0000000..128d75a --- /dev/null +++ b/src/main/java/io/as/modules/daycard/controller/ProfitSharingQueryController.java @@ -0,0 +1,187 @@ +package io.as.modules.daycard.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.common.utils.R; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.dao.ProfitSharingDetailDao; +import io.as.modules.daycard.dao.ProfitSharingOrderDao; +import io.as.modules.daycard.entity.ProfitSharingDetailEntity; +import io.as.modules.daycard.entity.ProfitSharingOrderEntity; +import io.as.modules.sys.dao.SysOrgDao; +import io.as.modules.sys.entity.SysOrgEntity; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.*; + +/** + * 分账记录查询 Controller + */ +@Api(tags = "分账记录查询") +@RestController +@RequestMapping("/manage/profit-sharing") +public class ProfitSharingQueryController { + + @Autowired + private ProfitSharingOrderDao profitSharingOrderDao; + + @Autowired + private ProfitSharingDetailDao profitSharingDetailDao; + + @Autowired + private SysOrgDao sysOrgDao; + + /** + * 分页查询分账单(支持 orderNo/status 筛选),返回详情列表 + */ + @ApiOperation("分页查询分账单") + @GetMapping("/list") + public R list(@RequestParam Map params) { + IPage page = new Query().getPage(params, "created_at", false); + + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + + String orderNo = (String) params.get("orderNo"); + if (StringUtils.isNotBlank(orderNo)) { + wrapper.eq("pay_order_no", orderNo); + } + String status = (String) params.get("status"); + if (StringUtils.isNotBlank(status)) { + wrapper.eq("status", status); + } + String outOrderNo = (String) params.get("outOrderNo"); + if (StringUtils.isNotBlank(outOrderNo)) { + wrapper.eq("out_order_no", outOrderNo); + } + + IPage result = profitSharingOrderDao.selectPage(page, wrapper); + + // 填充明细列表 + for (ProfitSharingOrderEntity order : result.getRecords()) { + QueryWrapper detailWrapper = new QueryWrapper<>(); + detailWrapper.eq("sharing_order_id", order.getId()); + List details = profitSharingDetailDao.selectList(detailWrapper); + order.setDetails(details); + } + + Map data = new HashMap<>(); + data.put("page", new PageUtils(result)); + return R.dataSuccess(data); + } + + /** + * 分账单详情 + details 子表 + */ + @ApiOperation("分账单详情") + @GetMapping("/info/{id}") + public R info(@PathVariable("id") Long id) { + ProfitSharingOrderEntity order = profitSharingOrderDao.selectById(id); + if (order == null) { + throw new ASException("分账单不存在"); + } + QueryWrapper detailWrapper = new QueryWrapper<>(); + detailWrapper.eq("sharing_order_id", id); + List details = profitSharingDetailDao.selectList(detailWrapper); + order.setDetails(details); + Map data = new HashMap<>(); + data.put("order", order); + return R.dataSuccess(data); + } + + /** + * 统计(参数 startDate, endDate, groupBy) + * 返回: { summary: {totalCount, totalAmount, successCount, successAmount}, groups: [{orgId, orgName, orgType, count, amount}] } + */ + @ApiOperation("分账统计") + @GetMapping("/stats") + public R stats(@RequestParam(value = "startDate", required = false) String startDate, + @RequestParam(value = "endDate", required = false) String endDate, + @RequestParam(value = "groupBy", required = false) String groupBy) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + + if (StringUtils.isNotBlank(startDate)) { + wrapper.ge("created_at", startDate + " 00:00:00"); + } + if (StringUtils.isNotBlank(endDate)) { + wrapper.le("created_at", endDate + " 23:59:59"); + } + + List orders = profitSharingOrderDao.selectList(wrapper); + + // 汇总 + int totalCount = orders.size(); + int totalAmount = 0; + int successCount = 0; + int successAmount = 0; + for (ProfitSharingOrderEntity order : orders) { + if (order.getTotalAmount() != null) { + totalAmount += order.getTotalAmount(); + } + if (DayCardConstants.SETTLE_STATUS_SUCCESS.equals(order.getStatus())) { + successCount++; + if (order.getTotalAmount() != null) { + successAmount += order.getTotalAmount(); + } + } + } + + Map summary = new HashMap<>(); + summary.put("totalCount", totalCount); + summary.put("totalAmount", totalAmount); + summary.put("successCount", successCount); + summary.put("successAmount", successAmount); + + // 分组统计 + List> groups = new ArrayList<>(); + if (StringUtils.isNotBlank(groupBy) && "org".equals(groupBy)) { + // 按组织分组:查询所有明细,按 orgId 聚合 + Map> orgMap = new HashMap<>(); + for (ProfitSharingOrderEntity order : orders) { + QueryWrapper detailWrapper = new QueryWrapper<>(); + detailWrapper.eq("sharing_order_id", order.getId()); + List details = profitSharingDetailDao.selectList(detailWrapper); + for (ProfitSharingDetailEntity detail : details) { + Long orgId = detail.getOrgId(); + if (orgId == null) { + continue; + } + Map group = orgMap.get(orgId); + if (group == null) { + group = new HashMap<>(); + group.put("orgId", orgId); + group.put("count", 0); + group.put("amount", 0); + group.put("orgRole", detail.getOrgRole()); + // 查询组织名称 + SysOrgEntity org = sysOrgDao.selectById(orgId); + if (org != null) { + group.put("orgName", org.getName()); + group.put("orgType", org.getType()); + } + orgMap.put(orgId, group); + } + group.put("count", (Integer) group.get("count") + 1); + if (detail.getAmount() != null) { + group.put("amount", (Integer) group.get("amount") + detail.getAmount()); + } + } + } + groups.addAll(orgMap.values()); + } + + Map result = new HashMap<>(); + result.put("summary", summary); + result.put("groups", groups); + return R.dataSuccess(result); + } +} diff --git a/src/main/java/io/as/modules/daycard/controller/ProfitSharingRuleController.java b/src/main/java/io/as/modules/daycard/controller/ProfitSharingRuleController.java new file mode 100644 index 0000000..815f00d --- /dev/null +++ b/src/main/java/io/as/modules/daycard/controller/ProfitSharingRuleController.java @@ -0,0 +1,252 @@ +package io.as.modules.daycard.controller; + +import io.as.common.annotation.AuditAction; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.entity.ProfitSharingRuleEntity; +import io.as.modules.daycard.entity.ProfitSharingRuleItemEntity; +import io.as.modules.daycard.service.ProfitSharingRuleService; +import io.as.modules.sys.entity.SysOrgEntity; +import io.as.modules.sys.service.SysOrgService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 分润规则 Controller + */ +@Api(tags = "分润规则管理") +@RestController +@RequestMapping("/manage/profit-rule") +public class ProfitSharingRuleController { + + @Autowired + private ProfitSharingRuleService profitSharingRuleService; + + @Autowired + private SysOrgService sysOrgService; + + /** + * 分页查询分润规则 + */ + @ApiOperation("分页查询分润规则") + @GetMapping("/list") + public R list(@RequestParam Map params) { + PageUtils page = profitSharingRuleService.queryPage(params); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + /** + * 分润规则详情(含 items 子表) + */ + @ApiOperation("分润规则详情") + @GetMapping("/info/{id}") + public R info(@PathVariable("id") Long id) { + ProfitSharingRuleEntity rule = profitSharingRuleService.getInfo(id); + if (rule == null) { + throw new ASException("分润规则不存在"); + } + Map data = new HashMap<>(); + data.put("rule", rule); + return R.dataSuccess(data); + } + + /** + * 新增分润规则 + */ + @ApiOperation("新增分润规则") + @AuditAction(value = "PROFIT_RULE_SAVE", description = "新增分润规则") + @PostMapping("/save") + public R save(@RequestBody ProfitSharingRuleEntity rule) { + if (StringUtils.isBlank(rule.getRuleName())) { + throw new ASException("规则名称不能为空"); + } + if (StringUtils.isBlank(rule.getScopeType())) { + throw new ASException("范围类型不能为空"); + } + profitSharingRuleService.saveRule(rule); + Map data = new HashMap<>(); + data.put("rule", rule); + return R.dataSuccess(data); + } + + /** + * 修改分润规则 + */ + @ApiOperation("修改分润规则") + @AuditAction(value = "PROFIT_RULE_UPDATE", description = "修改分润规则") + @PostMapping("/update") + public R update(@RequestBody ProfitSharingRuleEntity rule) { + if (rule.getId() == null) { + throw new ASException("id不能为空"); + } + profitSharingRuleService.updateRule(rule); + return R.ok(); + } + + /** + * 删除分润规则(逻辑删除) + */ + @ApiOperation("删除分润规则") + @AuditAction(value = "PROFIT_RULE_DELETE", description = "删除分润规则") + @PostMapping("/delete") + public R delete(@RequestBody Map body) { + Long id = Long.parseLong(body.get("id").toString()); + profitSharingRuleService.delete(id); + return R.ok(); + } + + /** + * 试算:根据门店和金额预览分润结果 + */ + @ApiOperation("分润试算") + @AuditAction(value = "PROFIT_RULE_PREVIEW", description = "分润试算") + @PostMapping("/preview") + public R preview(@RequestBody Map body) { + Long storeOrgId = Long.parseLong(body.get("storeOrgId").toString()); + Long amount = Long.parseLong(body.get("amount").toString()); + + // 1. 从 storeOrgId 查 sys_org 获取 mpath,解析出 channelOrgId 和 agentOrgId + SysOrgEntity storeOrg = sysOrgService.getById(storeOrgId); + if (storeOrg == null) { + throw new ASException("门店组织不存在"); + } + String mpath = storeOrg.getMpath(); + if (StringUtils.isBlank(mpath)) { + throw new ASException("门店组织 mpath 为空"); + } + String[] segments = mpath.split("\\."); + // segments[0] = PLATFORM, segments[1] = CHANNEL, segments[n-1] = STORE + Long platformOrgId = null; + Long channelOrgId = null; + Long agentOrgId = null; + if (segments.length >= 1) { + platformOrgId = Long.parseLong(segments[0]); + } + if (segments.length >= 2) { + channelOrgId = Long.parseLong(segments[1]); + } + // segments.length >= 4 表示路径中有代理商(PLATFORM.CHANNEL.AGENT.STORE) + if (segments.length >= 4) { + agentOrgId = Long.parseLong(segments[segments.length - 2]); + } + + // 2. 调用 matchRule 匹配规则 + ProfitSharingRuleEntity rule = profitSharingRuleService.matchRule(storeOrgId, agentOrgId, channelOrgId); + if (rule == null) { + throw new ASException("未匹配到分润规则"); + } + + // 3. 按 items 计算各方分账金额 + List items = rule.getItems(); + if (items == null || items.isEmpty()) { + throw new ASException("分润规则未配置明细"); + } + + // 按 sortOrder 排序 + items.sort((a, b) -> { + int sa = a.getSortOrder() == null ? 0 : a.getSortOrder(); + int sb = b.getSortOrder() == null ? 0 : b.getSortOrder(); + return Integer.compare(sa, sb); + }); + + // orgRole -> orgId 映射 + Map orgIdMap = new HashMap<>(); + orgIdMap.put(DayCardConstants.ORG_ROLE_PLATFORM, platformOrgId); + orgIdMap.put(DayCardConstants.ORG_ROLE_CHANNEL, channelOrgId); + orgIdMap.put(DayCardConstants.ORG_ROLE_AGENT, agentOrgId); + orgIdMap.put(DayCardConstants.ORG_ROLE_STORE, storeOrgId); + + // 找到最后一个 PLATFORM 项(尾差归平台) + int lastPlatformIndex = -1; + for (int i = 0; i < items.size(); i++) { + if (DayCardConstants.ORG_ROLE_PLATFORM.equals(items.get(i).getOrgRole())) { + lastPlatformIndex = i; + } + } + + // 计算非平台方 RATIO 比例之和 + BigDecimal nonPlatformTotalRatio = BigDecimal.ZERO; + + // 计算各方分账金额 + List> details = new ArrayList<>(); + long allocatedTotal = 0; + for (int i = 0; i < items.size(); i++) { + ProfitSharingRuleItemEntity item = items.get(i); + Map detail = new HashMap<>(); + detail.put("orgRole", item.getOrgRole()); + detail.put("shareType", item.getShareType()); + detail.put("shareValue", item.getShareValue()); + + Long orgId = orgIdMap.get(item.getOrgRole()); + detail.put("orgId", orgId); + + // 查询组织名称 + String orgName = null; + if (orgId != null) { + SysOrgEntity org = sysOrgService.getById(orgId); + if (org != null) { + orgName = org.getName(); + } + } + detail.put("orgName", orgName); + + long itemAmount; + if (i == lastPlatformIndex) { + // 最后一方 PLATFORM 用减法(尾差归平台) + itemAmount = amount - allocatedTotal; + } else if (DayCardConstants.SHARE_TYPE_FIXED.equals(item.getShareType())) { + // FIXED: shareValue 直接作为分 + itemAmount = item.getShareValue().longValue(); + allocatedTotal += itemAmount; + } else { + // RATIO: amount * shareValue / 100 + BigDecimal ratioAmount = BigDecimal.valueOf(amount) + .multiply(item.getShareValue()) + .divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN); + itemAmount = ratioAmount.longValue(); + allocatedTotal += itemAmount; + } + + // 统计非平台方 RATIO 比例之和 + if (!DayCardConstants.ORG_ROLE_PLATFORM.equals(item.getOrgRole()) + && DayCardConstants.SHARE_TYPE_RATIO.equals(item.getShareType()) + && item.getShareValue() != null) { + nonPlatformTotalRatio = nonPlatformTotalRatio.add(item.getShareValue()); + } + + detail.put("amount", itemAmount); + details.add(detail); + } + + // 5. warning 非 null 时表示超30% + String warning = null; + if (nonPlatformTotalRatio.compareTo(BigDecimal.valueOf(DayCardConstants.PROFIT_SHARE_LIMIT)) > 0) { + warning = "非平台方分润比例之和" + nonPlatformTotalRatio + "%超过上限" + DayCardConstants.PROFIT_SHARE_LIMIT + "%"; + } + + // 4. 返回结果 + Map result = new HashMap<>(); + result.put("ruleId", rule.getId()); + result.put("ruleName", rule.getRuleName()); + result.put("details", details); + result.put("nonPlatformTotalRatio", nonPlatformTotalRatio); + result.put("warning", warning); + Map data = new HashMap<>(); + data.put("data", result); + return R.dataSuccess(data); + } +} diff --git a/src/main/java/io/as/modules/daycard/dao/DayCardActivationCodeDao.java b/src/main/java/io/as/modules/daycard/dao/DayCardActivationCodeDao.java new file mode 100644 index 0000000..4c5c245 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/dao/DayCardActivationCodeDao.java @@ -0,0 +1,12 @@ +package io.as.modules.daycard.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.daycard.entity.DayCardActivationCodeEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 天卡设备激活码 DAO + */ +@Mapper +public interface DayCardActivationCodeDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/daycard/dao/DayCardImportBatchDao.java b/src/main/java/io/as/modules/daycard/dao/DayCardImportBatchDao.java new file mode 100644 index 0000000..047d5c6 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/dao/DayCardImportBatchDao.java @@ -0,0 +1,12 @@ +package io.as.modules.daycard.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.daycard.entity.DayCardImportBatchEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 天卡导入批次 + */ +@Mapper +public interface DayCardImportBatchDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/daycard/dao/DayCardOrderDao.java b/src/main/java/io/as/modules/daycard/dao/DayCardOrderDao.java new file mode 100644 index 0000000..9dba63d --- /dev/null +++ b/src/main/java/io/as/modules/daycard/dao/DayCardOrderDao.java @@ -0,0 +1,12 @@ +package io.as.modules.daycard.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.daycard.entity.DayCardOrderEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 天卡订单 + */ +@Mapper +public interface DayCardOrderDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/daycard/dao/DayCardPoolDao.java b/src/main/java/io/as/modules/daycard/dao/DayCardPoolDao.java new file mode 100644 index 0000000..0fd2684 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/dao/DayCardPoolDao.java @@ -0,0 +1,22 @@ +package io.as.modules.daycard.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.daycard.entity.DayCardPoolEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 天卡池 + */ +@Mapper +public interface DayCardPoolDao extends BaseMapper { + + /** + * 使用 SELECT ... FOR UPDATE SKIP LOCKED 抢占一张未使用的天卡, + * 避免多线程/多实例并发下单时分配到同一张卡。 + *

天卡池为通用池,不再按 partner 过滤

+ * + * @return 被当前事务锁定的可用天卡行,无可用时返回 null + */ + DayCardPoolEntity selectOneForUpdateSkipLocked(); +} diff --git a/src/main/java/io/as/modules/daycard/dao/DayCardPriceConfigDao.java b/src/main/java/io/as/modules/daycard/dao/DayCardPriceConfigDao.java new file mode 100644 index 0000000..f9e91af --- /dev/null +++ b/src/main/java/io/as/modules/daycard/dao/DayCardPriceConfigDao.java @@ -0,0 +1,12 @@ +package io.as.modules.daycard.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.daycard.entity.DayCardPriceConfigEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 天卡定价策略 Dao + */ +@Mapper +public interface DayCardPriceConfigDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/daycard/dao/DayCardProfitReceiverDao.java b/src/main/java/io/as/modules/daycard/dao/DayCardProfitReceiverDao.java new file mode 100644 index 0000000..1058213 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/dao/DayCardProfitReceiverDao.java @@ -0,0 +1,12 @@ +package io.as.modules.daycard.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.daycard.entity.DayCardProfitReceiverEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 天卡分账接收方 Dao + */ +@Mapper +public interface DayCardProfitReceiverDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/daycard/dao/DayCardProfitRuleDao.java b/src/main/java/io/as/modules/daycard/dao/DayCardProfitRuleDao.java new file mode 100644 index 0000000..7a68746 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/dao/DayCardProfitRuleDao.java @@ -0,0 +1,12 @@ +package io.as.modules.daycard.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.daycard.entity.DayCardProfitRuleEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 天卡分润规则 Dao + */ +@Mapper +public interface DayCardProfitRuleDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/daycard/dao/DayCardRefundDao.java b/src/main/java/io/as/modules/daycard/dao/DayCardRefundDao.java new file mode 100644 index 0000000..e258603 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/dao/DayCardRefundDao.java @@ -0,0 +1,12 @@ +package io.as.modules.daycard.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.daycard.entity.DayCardRefundEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 天卡退款单 Dao + */ +@Mapper +public interface DayCardRefundDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/daycard/dao/DayCardSettleOrderDao.java b/src/main/java/io/as/modules/daycard/dao/DayCardSettleOrderDao.java new file mode 100644 index 0000000..adc5c22 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/dao/DayCardSettleOrderDao.java @@ -0,0 +1,12 @@ +package io.as.modules.daycard.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.daycard.entity.DayCardSettleOrderEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 天卡分账单 Dao + */ +@Mapper +public interface DayCardSettleOrderDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/daycard/dao/DayCardSettleRecordDao.java b/src/main/java/io/as/modules/daycard/dao/DayCardSettleRecordDao.java new file mode 100644 index 0000000..67de3dc --- /dev/null +++ b/src/main/java/io/as/modules/daycard/dao/DayCardSettleRecordDao.java @@ -0,0 +1,12 @@ +package io.as.modules.daycard.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.daycard.entity.DayCardSettleRecordEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 天卡分账记录 Dao + */ +@Mapper +public interface DayCardSettleRecordDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/daycard/dao/DayCardSettleRuleDao.java b/src/main/java/io/as/modules/daycard/dao/DayCardSettleRuleDao.java new file mode 100644 index 0000000..c35d292 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/dao/DayCardSettleRuleDao.java @@ -0,0 +1,12 @@ +package io.as.modules.daycard.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.daycard.entity.DayCardSettleRuleEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 天卡分账规则 Dao + */ +@Mapper +public interface DayCardSettleRuleDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/daycard/dao/OrgSettlementAccountDao.java b/src/main/java/io/as/modules/daycard/dao/OrgSettlementAccountDao.java new file mode 100644 index 0000000..c306978 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/dao/OrgSettlementAccountDao.java @@ -0,0 +1,12 @@ +package io.as.modules.daycard.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.daycard.entity.OrgSettlementAccountEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 组织分账接收方 Dao + */ +@Mapper +public interface OrgSettlementAccountDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/daycard/dao/ProfitSharingDetailDao.java b/src/main/java/io/as/modules/daycard/dao/ProfitSharingDetailDao.java new file mode 100644 index 0000000..2d874c2 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/dao/ProfitSharingDetailDao.java @@ -0,0 +1,12 @@ +package io.as.modules.daycard.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.daycard.entity.ProfitSharingDetailEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 分账明细 Dao + */ +@Mapper +public interface ProfitSharingDetailDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/daycard/dao/ProfitSharingOrderDao.java b/src/main/java/io/as/modules/daycard/dao/ProfitSharingOrderDao.java new file mode 100644 index 0000000..5e8aed8 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/dao/ProfitSharingOrderDao.java @@ -0,0 +1,12 @@ +package io.as.modules.daycard.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.daycard.entity.ProfitSharingOrderEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 分账单 Dao + */ +@Mapper +public interface ProfitSharingOrderDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/daycard/dao/ProfitSharingRuleDao.java b/src/main/java/io/as/modules/daycard/dao/ProfitSharingRuleDao.java new file mode 100644 index 0000000..bac5b08 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/dao/ProfitSharingRuleDao.java @@ -0,0 +1,12 @@ +package io.as.modules.daycard.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.daycard.entity.ProfitSharingRuleEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 分润规则 Dao + */ +@Mapper +public interface ProfitSharingRuleDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/daycard/dao/ProfitSharingRuleItemDao.java b/src/main/java/io/as/modules/daycard/dao/ProfitSharingRuleItemDao.java new file mode 100644 index 0000000..dba91c4 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/dao/ProfitSharingRuleItemDao.java @@ -0,0 +1,12 @@ +package io.as.modules.daycard.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.daycard.entity.ProfitSharingRuleItemEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 分润规则明细 Dao + */ +@Mapper +public interface ProfitSharingRuleItemDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/daycard/entity/DayCardActivationCodeEntity.java b/src/main/java/io/as/modules/daycard/entity/DayCardActivationCodeEntity.java new file mode 100644 index 0000000..4b959cc --- /dev/null +++ b/src/main/java/io/as/modules/daycard/entity/DayCardActivationCodeEntity.java @@ -0,0 +1,52 @@ +package io.as.modules.daycard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 天卡设备激活码 + *

管理后台按门店生成,App 端 init 时输入激活码完成设备注册绑定

+ */ +@Data +@TableName("day_card_activation_code") +public class DayCardActivationCodeEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** 激活码(全局唯一) */ + private String code; + + /** 渠道组织ID */ + private Long channelOrgId; + + /** 门店组织ID */ + private Long storeOrgId; + + /** 状态:ACTIVE/DISABLED */ + private String status; + + /** 最大可激活设备数(0=不限) */ + private Integer maxDeviceCount; + + /** 已激活设备数 */ + private Integer activatedCount; + + /** 备注 */ + private String remark; + + /** 删除标记:UNDEL/DEL */ + private String deleteFlag; + + /** 创建时间 */ + private Date createdAt; + + /** 更新时间 */ + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/daycard/entity/DayCardImportBatchEntity.java b/src/main/java/io/as/modules/daycard/entity/DayCardImportBatchEntity.java new file mode 100644 index 0000000..da394a6 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/entity/DayCardImportBatchEntity.java @@ -0,0 +1,46 @@ +package io.as.modules.daycard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 天卡导入批次 + */ +@Data +@TableName("day_card_import_batch") +public class DayCardImportBatchEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 批次号 */ + private String batchNo; + /** 合作商ID */ + private Long partnerId; + /** 状态:PROCESSING/SUCCEEDED/PARTIAL_FAILED/FAILED */ + private String status; + /** 总数 */ + private Integer totalCount; + /** 成功数 */ + private Integer successCount; + /** 失败数 */ + private Integer failCount; + /** 结果报告URL */ + private String resultReportUrl; + /** 备注 */ + private String remark; + /** 创建人 */ + private Long createdBy; + /** 删除标记 */ + private String deleteFlag; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/daycard/entity/DayCardOrderEntity.java b/src/main/java/io/as/modules/daycard/entity/DayCardOrderEntity.java new file mode 100644 index 0000000..ab87c05 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/entity/DayCardOrderEntity.java @@ -0,0 +1,82 @@ +package io.as.modules.daycard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 天卡订单 + */ +@Data +@TableName("day_card_order") +public class DayCardOrderEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 订单号 */ + private String orderNo; + /** 合作商ID */ + private Long partnerId; + /** 设备ID */ + private Long deviceId; + /** 门店组织ID */ + private Long storeOrgId; + /** 渠道组织ID */ + private Long channelOrgId; + /** 支付后写入的卡号 */ + private String cardNo; + /** 订单状态:CREATED/PAYING/PAID/PAY_FAILED/CANCELLED */ + private String status; + /** 订单金额 */ + private BigDecimal amount; + /** 支付单号 */ + private String payNo; + /** 支付金额 */ + private BigDecimal payAmount; + /** 支付时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date payTime; + /** 订单过期时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date expireTime; + /** 取消时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date cancelTime; + /** 删除标记 */ + private String deleteFlag; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; + /** 支付渠道 MOCK/WECHAT */ + private String payChannel; + /** 微信预支付ID */ + private String prepayId; + /** 微信扫码支付链接 */ + private String codeUrl; + /** 设备SN(冗余,便于排查) */ + private String deviceSn; + /** 设备MAC(冗余) */ + private String mac; + /** 代理商组织ID */ + private Long agentOrgId; + /** 组织链路快照(如 1.5.12.) */ + private String orgPathSnapshot; + /** 微信支付订单号 */ + private String channelOrderNo; + + /** 合作商名称(非表字段) */ + @TableField(exist = false) + private String partnerName; + /** 设备MAC地址(非表字段) */ + @TableField(exist = false) + private String deviceMac; +} diff --git a/src/main/java/io/as/modules/daycard/entity/DayCardPoolEntity.java b/src/main/java/io/as/modules/daycard/entity/DayCardPoolEntity.java new file mode 100644 index 0000000..736a669 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/entity/DayCardPoolEntity.java @@ -0,0 +1,59 @@ +package io.as.modules.daycard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 天卡池 + */ +@Data +@TableName("day_card_pool") +public class DayCardPoolEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 卡密 */ + private String cardCode; + /** 第三方引用标识 */ + private String thirdPartyRef; + /** 第三方卡号 */ + private String thirdCardNo; + /** 卡类型,默认DAY */ + private String cardType; + /** 合作商ID */ + private Long partnerId; + /** 状态:UNUSED/RESERVED/USED */ + private String status; + /** 激活状态:PENDING/SUCCESS/FAILED */ + private String activateStatus; + /** 激活失败原因 */ + private String activateError; + /** 激活成功时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date activatedAt; + /** 关联订单号 */ + private String orderNo; + /** 设备ID */ + private Long deviceId; + /** 导入批次ID */ + private Long importBatchId; + /** 卡过期时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date expireAt; + /** 使用时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date usedAt; + /** 删除标记 */ + private String deleteFlag; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/daycard/entity/DayCardPriceConfigEntity.java b/src/main/java/io/as/modules/daycard/entity/DayCardPriceConfigEntity.java new file mode 100644 index 0000000..6bdb9ba --- /dev/null +++ b/src/main/java/io/as/modules/daycard/entity/DayCardPriceConfigEntity.java @@ -0,0 +1,41 @@ +package io.as.modules.daycard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 天卡定价策略 + */ +@Data +@TableName("day_card_price_config") +public class DayCardPriceConfigEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 合作伙伴ID */ + private Long partnerId; + /** 渠道组织ID(可空=全渠道默认) */ + private Long channelOrgId; + /** 门店组织ID(空=渠道级或平台级) */ + private Long storeOrgId; + /** 天卡价格(元) */ + private BigDecimal price; + /** 策略名称 */ + private String name; + /** 状态 ACTIVE/DISABLED */ + private String status; + /** 删除标记 */ + private String deleteFlag; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/daycard/entity/DayCardProfitReceiverEntity.java b/src/main/java/io/as/modules/daycard/entity/DayCardProfitReceiverEntity.java new file mode 100644 index 0000000..420faa5 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/entity/DayCardProfitReceiverEntity.java @@ -0,0 +1,42 @@ +package io.as.modules.daycard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 天卡分账接收方 + */ +@Data +@TableName("day_card_profit_receiver") +public class DayCardProfitReceiverEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 名称 */ + private String name; + /** 合作伙伴ID */ + private Long partnerId; + /** 接收方类型 MERCHANT_ID/PERSONAL_OPENID */ + private String receiverType; + /** 分账接收方账户 */ + private String receiverAccount; + /** 接收方名称 */ + private String receiverName; + /** 微信绑定状态 BINDED/UNBOUND */ + private String wxBindStatus; + /** 备注 */ + private String remark; + /** 删除标记 */ + private String deleteFlag; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/daycard/entity/DayCardProfitRuleEntity.java b/src/main/java/io/as/modules/daycard/entity/DayCardProfitRuleEntity.java new file mode 100644 index 0000000..64d2e3d --- /dev/null +++ b/src/main/java/io/as/modules/daycard/entity/DayCardProfitRuleEntity.java @@ -0,0 +1,53 @@ +package io.as.modules.daycard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 天卡分润规则 + */ +@Data +@TableName("day_card_profit_rule") +public class DayCardProfitRuleEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 规则名称 */ + private String name; + /** 合作伙伴ID */ + private Long partnerId; + /** 渠道组织ID */ + private Long channelOrgId; + /** 门店组织ID(空=渠道级或合作商级) */ + private Long storeOrgId; + /** 接收方类型 MERCHANT_ID/PERSONAL_OPENID */ + private String receiverType; + /** 分账接收方账户 */ + private String receiverAccount; + /** 接收方名称 */ + private String receiverName; + /** 分润比例(如0.3000=30%)或固定金额 */ + private BigDecimal shareRatio; + /** 分润类型 RATIO/FIXED */ + private String shareType; + /** 优先级(数字越大优先级越高) */ + private Integer priority; + /** 状态 ACTIVE/DISABLED */ + private String status; + /** 备注 */ + private String remark; + /** 删除标记 */ + private String deleteFlag; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/daycard/entity/DayCardRefundEntity.java b/src/main/java/io/as/modules/daycard/entity/DayCardRefundEntity.java new file mode 100644 index 0000000..8b17b08 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/entity/DayCardRefundEntity.java @@ -0,0 +1,46 @@ +package io.as.modules.daycard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 天卡退款单 + */ +@Data +@TableName("day_card_refund") +public class DayCardRefundEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 退款单号 */ + private String refundNo; + /** 关联订单号 */ + private String orderNo; + /** 微信退款单号 */ + private String wxRefundId; + /** 退款金额 */ + private BigDecimal refundAmount; + /** 退款原因 */ + private String reason; + /** 状态 PENDING/PROCESSING/SUCCESS/FAILED/CLOSED */ + private String status; + /** 错误信息 */ + private String errorMessage; + /** 退款完成时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date settledAt; + /** 删除标记 */ + private String deleteFlag; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/daycard/entity/DayCardSettleOrderEntity.java b/src/main/java/io/as/modules/daycard/entity/DayCardSettleOrderEntity.java new file mode 100644 index 0000000..ab4b6b9 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/entity/DayCardSettleOrderEntity.java @@ -0,0 +1,54 @@ +package io.as.modules.daycard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 天卡分账单 + */ +@Data +@TableName("day_card_settle_order") +public class DayCardSettleOrderEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 分账单号 */ + private String settleNo; + /** 订单号 */ + private String orderNo; + /** 合作伙伴ID */ + private Long partnerId; + /** 分润规则ID */ + private Long ruleId; + /** 接收方账户 */ + private String receiverAccount; + /** 接收方名称 */ + private String receiverName; + /** 分润比例/金额 */ + private BigDecimal shareRatio; + /** 分润类型 RATIO/FIXED */ + private String shareType; + /** 分账金额 */ + private BigDecimal settleAmount; + /** 订单总金额 */ + private BigDecimal totalAmount; + /** 状态 PENDING/PROCESSING/SUCCESS/FAILED */ + private String status; + /** 错误信息 */ + private String errorMessage; + /** 分账完成时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date settledAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/daycard/entity/DayCardSettleRecordEntity.java b/src/main/java/io/as/modules/daycard/entity/DayCardSettleRecordEntity.java new file mode 100644 index 0000000..7e21dfc --- /dev/null +++ b/src/main/java/io/as/modules/daycard/entity/DayCardSettleRecordEntity.java @@ -0,0 +1,46 @@ +package io.as.modules.daycard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 天卡分账记录 + */ +@Data +@TableName("day_card_settle_record") +public class DayCardSettleRecordEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 订单号 */ + private String orderNo; + /** 分账规则ID */ + private Long settleRuleId; + /** 订单总金额 */ + private BigDecimal totalAmount; + /** 分账比例 */ + private BigDecimal shareRatio; + /** 分账金额 */ + private BigDecimal settleAmount; + /** 接收方账户 */ + private String receiverAccount; + /** 状态 PENDING/SUCCESS/FAILED */ + private String status; + /** 错误信息 */ + private String errorMessage; + /** 分账完成时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date settledAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/daycard/entity/DayCardSettleRuleEntity.java b/src/main/java/io/as/modules/daycard/entity/DayCardSettleRuleEntity.java new file mode 100644 index 0000000..46dea30 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/entity/DayCardSettleRuleEntity.java @@ -0,0 +1,47 @@ +package io.as.modules.daycard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 天卡分账规则 + */ +@Data +@TableName("day_card_settle_rule") +public class DayCardSettleRuleEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 规则名称 */ + private String name; + /** 合作伙伴ID */ + private Long partnerId; + /** 渠道组织ID */ + private Long channelOrgId; + /** 门店组织ID(空=渠道级) */ + private Long storeOrgId; + /** 分账接收方账户 */ + private String receiverAccount; + /** 接收方名称 */ + private String receiverName; + /** 分账比例(如0.3000=30%) */ + private BigDecimal shareRatio; + /** 接收方类型 MERCHANT_ID/PERSONAL_OPENID */ + private String receiverType; + /** 状态 ACTIVE/DISABLED */ + private String status; + /** 删除标记 */ + private String deleteFlag; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/daycard/entity/OrgSettlementAccountEntity.java b/src/main/java/io/as/modules/daycard/entity/OrgSettlementAccountEntity.java new file mode 100644 index 0000000..568ecd4 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/entity/OrgSettlementAccountEntity.java @@ -0,0 +1,47 @@ +package io.as.modules.daycard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 组织分账接收方 + */ +@Data +@TableName("org_settlement_account") +public class OrgSettlementAccountEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 关联组织 */ + private Long orgId; + /** 支付渠道 */ + private String payChannel; + /** 接收方类型 MERCHANT_ID / PERSONAL_OPENID */ + private String receiverType; + /** 接收方账号(商户号或OpenID) */ + private String receiverAccount; + /** 接收方名称 */ + private String receiverName; + /** 绑定状态 PENDING/ACTIVE/FAILED */ + private String bindStatus; + /** 微信侧绑定ID */ + private String externalBindId; + /** 删除标记 */ + private String deleteFlag; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; + + /** 组织名称(非表字段) */ + @TableField(exist = false) + private String orgName; +} diff --git a/src/main/java/io/as/modules/daycard/entity/ProfitSharingDetailEntity.java b/src/main/java/io/as/modules/daycard/entity/ProfitSharingDetailEntity.java new file mode 100644 index 0000000..e9492e0 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/entity/ProfitSharingDetailEntity.java @@ -0,0 +1,49 @@ +package io.as.modules.daycard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 分账明细 + */ +@Data +@TableName("profit_sharing_detail") +public class ProfitSharingDetailEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 关联分账单 */ + private Long sharingOrderId; + /** 接收方组织ID */ + private Long orgId; + /** 组织角色 PLATFORM/CHANNEL/AGENT/STORE */ + private String orgRole; + /** 接收方账号 */ + private String receiverAccount; + /** 接收方类型 */ + private String receiverType; + /** 分账金额(分) */ + private Integer amount; + /** 分账描述 */ + private String description; + /** 状态 PENDING/SUCCESS/FAILED */ + private String status; + /** 微信侧分账单号 */ + private String externalOrderId; + /** 成功时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date successTime; + /** 失败原因 */ + private String failReason; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/daycard/entity/ProfitSharingOrderEntity.java b/src/main/java/io/as/modules/daycard/entity/ProfitSharingOrderEntity.java new file mode 100644 index 0000000..733e35d --- /dev/null +++ b/src/main/java/io/as/modules/daycard/entity/ProfitSharingOrderEntity.java @@ -0,0 +1,52 @@ +package io.as.modules.daycard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; +import java.util.List; + +/** + * 分账单 + */ +@Data +@TableName("profit_sharing_order") +public class ProfitSharingOrderEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 关联支付订单号 */ + private String payOrderNo; + /** 微信支付订单号 */ + private String transactionId; + /** 分账方内部单号 */ + private String outOrderNo; + /** 状态 PENDING/SUCCESS/FAILED/PARTIAL */ + private String status; + /** 分账总金额(分) */ + private Integer totalAmount; + /** 命中的规则ID */ + private Long ruleId; + /** 规则快照(JSON) */ + private String ruleSnapshot; + /** 组织链路快照 */ + private String orgPathSnapshot; + /** 分账渠道 */ + private String sharingChannel; + /** 删除标记 */ + private String deleteFlag; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; + + /** 分账明细列表(非表字段) */ + @TableField(exist = false) + private List details; +} diff --git a/src/main/java/io/as/modules/daycard/entity/ProfitSharingRuleEntity.java b/src/main/java/io/as/modules/daycard/entity/ProfitSharingRuleEntity.java new file mode 100644 index 0000000..542df34 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/entity/ProfitSharingRuleEntity.java @@ -0,0 +1,66 @@ +package io.as.modules.daycard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; +import java.util.List; + +/** + * 分润规则(主表) + */ +@Data +@TableName("profit_sharing_rule") +public class ProfitSharingRuleEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** 规则名称 */ + private String ruleName; + + /** 范围类型 GLOBAL/CHANNEL/AGENT/STORE */ + private String scopeType; + + /** 关联组织ID(GLOBAL时为空) */ + private Long scopeOrgId; + + /** 优先级 */ + private Integer priority; + + /** 状态 ACTIVE/DISABLED */ + private String status; + + /** 生效开始时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date effectiveFrom; + + /** 生效结束时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date effectiveTo; + + /** 删除标记 UNDEL/DEL */ + private String deleteFlag; + + /** 创建时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + + /** 更新时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; + + /** 子表:分润规则明细(非表字段) */ + @TableField(exist = false) + private List items; + + /** 关联组织名称(非表字段) */ + @TableField(exist = false) + private String scopeOrgName; +} diff --git a/src/main/java/io/as/modules/daycard/entity/ProfitSharingRuleItemEntity.java b/src/main/java/io/as/modules/daycard/entity/ProfitSharingRuleItemEntity.java new file mode 100644 index 0000000..64d97fe --- /dev/null +++ b/src/main/java/io/as/modules/daycard/entity/ProfitSharingRuleItemEntity.java @@ -0,0 +1,46 @@ +package io.as.modules.daycard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 分润规则明细(子表) + */ +@Data +@TableName("profit_sharing_rule_item") +public class ProfitSharingRuleItemEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** 关联规则ID */ + private Long ruleId; + + /** 组织角色 PLATFORM/CHANNEL/AGENT/STORE */ + private String orgRole; + + /** 分润类型 RATIO/FIXED */ + private String shareType; + + /** 分润值(比例:百分比;固定:分) */ + private BigDecimal shareValue; + + /** 排序 */ + private Integer sortOrder; + + /** 创建时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + + /** 更新时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/daycard/form/DayCardOrderCreateRequest.java b/src/main/java/io/as/modules/daycard/form/DayCardOrderCreateRequest.java new file mode 100644 index 0000000..79ae5f0 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/form/DayCardOrderCreateRequest.java @@ -0,0 +1,22 @@ +package io.as.modules.daycard.form; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 天卡订单创建请求 + */ +@Data +public class DayCardOrderCreateRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** 设备MAC(优先) */ + private String mac; + /** 设备SN(次优) */ + private String deviceSn; + /** 设备UID(兜底) */ + private String deviceUid; + /** 媒资ID */ + private String mediaId; +} diff --git a/src/main/java/io/as/modules/daycard/service/DayCardActivationCodeService.java b/src/main/java/io/as/modules/daycard/service/DayCardActivationCodeService.java new file mode 100644 index 0000000..65fe9b9 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/DayCardActivationCodeService.java @@ -0,0 +1,68 @@ +package io.as.modules.daycard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.modules.daycard.entity.DayCardActivationCodeEntity; + +import java.util.List; + +/** + * 天卡设备激活码服务 + */ +public interface DayCardActivationCodeService extends IService { + + /** + * 根据激活码查询有效记录(未删除、状态为 ACTIVE) + * + * @param code 激活码字符串 + * @return 激活码实体,不存在或无效返回 null + */ + DayCardActivationCodeEntity getByCode(String code); + + /** + * 按门店查询激活码列表 + * + * @param storeOrgId 门店组织ID + * @return 激活码列表 + */ + List listByStoreOrgId(Long storeOrgId); + + /** + * 为门店生成激活码(每门店一个,已存在则返回已有的) + * + * @param storeOrgId 门店组织ID + * @param channelOrgId 渠道组织ID + * @param remark 备注(可选) + * @return 激活码实体 + */ + DayCardActivationCodeEntity generateForStore(Long storeOrgId, Long channelOrgId, String remark); + + /** + * 重新生成激活码 code(旧 code 失效) + * + * @param id 激活码记录ID + * @return 新激活码字符串 + */ + String regenerateCode(Long id); + + /** + * 启用/禁用激活码 + * + * @param id 激活码记录ID + * @param status 目标状态:ACTIVE/DISABLED + */ + void updateStatus(Long id, String status); + + /** + * 删除激活码(软删除) + * + * @param id 激活码记录ID + */ + void delete(Long id); + + /** + * 增加已激活设备计数 + * + * @param id 激活码记录ID + */ + void incrementActivatedCount(Long id); +} diff --git a/src/main/java/io/as/modules/daycard/service/DayCardOrderService.java b/src/main/java/io/as/modules/daycard/service/DayCardOrderService.java new file mode 100644 index 0000000..982125a --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/DayCardOrderService.java @@ -0,0 +1,119 @@ +package io.as.modules.daycard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.daycard.entity.DayCardOrderEntity; +import io.as.modules.daycard.entity.DayCardPoolEntity; +import io.as.modules.daycard.entity.DayCardPriceConfigEntity; + +import java.math.BigDecimal; +import java.util.Map; + +/** + * 天卡订单 Service + */ +public interface DayCardOrderService extends IService { + + /** + * 分页查询天卡订单 + * + * @param params 查询参数 + * @param partnerId 合作商ID + */ + PageUtils queryPage(Map params, Long partnerId); + + /** + * 创建天卡订单(从 sign 鉴权上下文获取 deviceId) + *

流程:Redis防重 -> 解析设备组织链路 -> 按渠道定价 -> 生成订单号 -> 插入订单 -> SKIP LOCKED抢卡 -> 乐观锁更新卡状态 -> 返回订单

+ * + * @param deviceId 设备ID(从 sign 解析) + */ + DayCardOrderEntity createOrder(Long deviceId); + + /** + * 创建天卡订单(通过设备标识,扫码后调用) + *

流程:Redis防重(mac/deviceSn/deviceUid)-> 解析设备组织链路 -> 服务端定价 -> 生成订单号 -> 插入订单 -> SKIP LOCKED抢卡 -> 乐观锁更新卡状态 -> 返回订单

+ * + * @param mac 设备MAC(优先) + * @param deviceSn 设备SN(次优) + * @param deviceUid 设备UID(兜底) + * @param partnerId 合作商ID + */ + DayCardOrderEntity createOrder(String mac, String deviceSn, String deviceUid, Long partnerId); + + /** + * 支付回调 + *

流程:幂等检查 -> 乐观锁更新订单PAID -> 更新卡状态USED -> 异步激活 -> 清理Redis

+ * + * @param orderNo 订单号 + * @param payNo 支付单号 + * @param payAmount 支付金额 + */ + void payCallback(String orderNo, String payNo, BigDecimal payAmount); + + /** + * 取消超时未支付的订单 + *

查询超时订单(状态CREATED, expire_time < now, LIMIT 200)→ 逐个取消(释放卡+更新订单CANCELLED)

+ * + * @return 取消的订单数量 + */ + int cancelTimeoutOrders(); + + /** + * 根据订单号查询订单 + * + * @param orderNo 订单号 + */ + DayCardOrderEntity getByOrderNo(String orderNo); + + /** + * 查询设备的天卡权益(激活后 App 获取播放凭证) + *

从 day_card_pool 表查询 status=USED 且 expire_at > NOW() 的卡,返回 thirdCardNo 和 expireAt

+ * + * @param deviceId 设备ID + * @return 天卡池实体,无有效权益时返回 null + */ + DayCardPoolEntity getDeviceBenefits(Long deviceId); + + /** + * 查询设备生效的天卡定价(供前端二维码展示) + *

按 3 层维度匹配:门店 > 渠道 > 平台默认

+ * + * @param deviceId 设备ID + * @return 定价策略,无匹配时返回 null + */ + DayCardPriceConfigEntity getDevicePrice(Long deviceId); + + /** + * 解绑设备天卡权益(Web端手动操作) + *

将 USED 卡转为 REUSABLE,释放设备绑定,保留橘视厅权益供后续设备复用

+ * + * @param deviceId 设备ID + */ + void unbindDevice(Long deviceId); + + /** + * 设备组织解绑(更换安装场所时使用) + *

释放设备当前天卡权益并清除门店组织关联(storeOrgId), + * 设备可在新场所用新激活码重新激活绑定。

+ * + * @param deviceId 设备ID + */ + void unbindDeviceOrg(Long deviceId); + + /** + * 异步激活天卡:调用橘视厅接口 + *

通过 Spring AOP 代理调用以确保 @Async 生效

+ * + * @param order 天卡订单 + */ + void asyncActivateCard(DayCardOrderEntity order); + + /** + * 重试激活失败的天卡 + *

补偿定时任务调用:查询 activate_status=FAILED 的卡,重新激活

+ * + * @return 重试的卡数量 + */ + int retryFailedActivations(); +} diff --git a/src/main/java/io/as/modules/daycard/service/DayCardPoolService.java b/src/main/java/io/as/modules/daycard/service/DayCardPoolService.java new file mode 100644 index 0000000..5383225 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/DayCardPoolService.java @@ -0,0 +1,49 @@ +package io.as.modules.daycard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.daycard.entity.DayCardPoolEntity; + +import java.util.List; +import java.util.Map; + +/** + * 天卡池 Service + */ +public interface DayCardPoolService extends IService { + + /** + * 分页查询天卡池 + * + * @param params 查询参数 + * @param partnerId 合作商ID + */ + PageUtils queryPage(Map params, Long partnerId); + + /** + * 获取可用天卡数量 + * + * @param partnerId 合作商ID + */ + int getAvailableCount(Long partnerId); + + /** + * 批量导入天卡卡密 + *

只需提供 cardCode(橘视厅激活码),thirdPartyRef 和 thirdCardNo 由服务端自动生成

+ * + * @param cardCodes 激活码列表(从橘视厅/供应商获取的真实卡密) + * @param partnerId 合作商ID + * @return 成功导入数量 + */ + int importCards(java.util.List cardCodes, Long partnerId); + + /** + * 生成全局唯一的第三方引用号(uid):yyyyMMdd-HHmmss-xxxx + */ + String generateUniqueThirdPartyRef(); + + /** + * 生成 24 位随机第三方卡号(0-9 A-Z a-z) + */ + String generateThirdCardNo(); +} diff --git a/src/main/java/io/as/modules/daycard/service/DayCardPriceConfigService.java b/src/main/java/io/as/modules/daycard/service/DayCardPriceConfigService.java new file mode 100644 index 0000000..eb5b320 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/DayCardPriceConfigService.java @@ -0,0 +1,40 @@ +package io.as.modules.daycard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.daycard.entity.DayCardPriceConfigEntity; + +import java.math.BigDecimal; +import java.util.Map; + +/** + * 天卡定价策略 Service + */ +public interface DayCardPriceConfigService extends IService { + + /** + * 分页查询定价策略 + */ + PageUtils queryPage(Map params); + + /** + * 获取有效定价策略(3 层维度,优先级从高到低) + *

1. 门店配置价(storeOrgId 命中)

+ *

2. 渠道配置价(storeOrgId 为空,channelOrgId 命中)

+ *

3. 平台默认价兜底(storeOrgId、channelOrgId 均为空)

+ * + * @param channelOrgId 渠道组织ID(可空) + * @param storeOrgId 门店组织ID(可空) + * @return 定价策略,无匹配时返回 null + */ + DayCardPriceConfigEntity getEffectivePrice(Long channelOrgId, Long storeOrgId); + + /** + * 根据组织链路获取价格金额 + * + * @param channelOrgId 渠道组织ID(可空) + * @param storeOrgId 门店组织ID(可空) + * @return 价格金额,无匹配时返回 null + */ + BigDecimal getPrice(Long channelOrgId, Long storeOrgId); +} diff --git a/src/main/java/io/as/modules/daycard/service/DayCardProfitReceiverService.java b/src/main/java/io/as/modules/daycard/service/DayCardProfitReceiverService.java new file mode 100644 index 0000000..c3dc7a8 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/DayCardProfitReceiverService.java @@ -0,0 +1,45 @@ +package io.as.modules.daycard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.daycard.entity.DayCardProfitReceiverEntity; + +import java.util.Map; + +/** + * 天卡分账接收方 Service + */ +public interface DayCardProfitReceiverService extends IService { + + /** + * 分页查询分账接收方 + */ + PageUtils queryPage(Map params); + + /** + * 根据ID获取分账接收方 + */ + DayCardProfitReceiverEntity getById(Long id); + + /** + * 新增分账接收方 + */ + void saveReceiver(DayCardProfitReceiverEntity receiver); + + /** + * 修改分账接收方 + */ + void updateReceiver(DayCardProfitReceiverEntity receiver); + + /** + * 批量删除分账接收方(软删除) + */ + void delete(Long[] ids); + + /** + * 同步接收方到微信(占位方法,实际微信API待对接) + * + * @param id 接收方ID + */ + void syncToWechat(Long id); +} diff --git a/src/main/java/io/as/modules/daycard/service/DayCardProfitRuleService.java b/src/main/java/io/as/modules/daycard/service/DayCardProfitRuleService.java new file mode 100644 index 0000000..22232f2 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/DayCardProfitRuleService.java @@ -0,0 +1,50 @@ +package io.as.modules.daycard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.daycard.entity.DayCardProfitRuleEntity; + +import java.util.List; +import java.util.Map; + +/** + * 天卡分润规则 Service + */ +public interface DayCardProfitRuleService extends IService { + + /** + * 分页查询分润规则 + */ + PageUtils queryPage(Map params); + + /** + * 根据ID获取分润规则 + */ + DayCardProfitRuleEntity getById(Long id); + + /** + * 新增分润规则(30%校验) + */ + void saveRule(DayCardProfitRuleEntity rule); + + /** + * 修改分润规则(30%校验) + */ + void updateRule(DayCardProfitRuleEntity rule); + + /** + * 批量删除分润规则(软删除) + */ + void delete(Long[] ids); + + /** + * 逐级匹配分润规则 + *

优先门店级 -> 渠道级 -> 合作商级

+ * + * @param partnerId 合作伙伴ID + * @param channelOrgId 渠道组织ID + * @param storeOrgId 门店组织ID(可空) + * @return 匹配的分润规则列表,按 priority DESC 排序 + */ + List matchRules(Long partnerId, Long channelOrgId, Long storeOrgId); +} diff --git a/src/main/java/io/as/modules/daycard/service/DayCardRefundService.java b/src/main/java/io/as/modules/daycard/service/DayCardRefundService.java new file mode 100644 index 0000000..d5b7bb3 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/DayCardRefundService.java @@ -0,0 +1,45 @@ +package io.as.modules.daycard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.daycard.entity.DayCardRefundEntity; + +import java.util.Map; + +/** + * 天卡退款 Service + */ +public interface DayCardRefundService extends IService { + + /** + * 分页查询退款记录 + */ + PageUtils queryPage(Map params); + + /** + * 根据退款单号查询退款记录 + * + * @param refundNo 退款单号 + * @return 退款记录 + */ + DayCardRefundEntity getByRefundNo(String refundNo); + + /** + * 发起退款 + *

流程:校验订单PAID -> 生成退款单号 -> 创建退款记录 -> 释放卡 -> 回退分账 -> 微信退款 -> 更新退款成功

+ * + * @param orderNo 订单号 + * @param reason 退款原因 + * @return 退款记录 + */ + DayCardRefundEntity refund(String orderNo, String reason); + + /** + * 更新退款状态 + * + * @param id 退款记录ID + * @param status 退款状态 + * @param errorMessage 错误信息(可为空) + */ + void updateStatus(Long id, String status, String errorMessage); +} diff --git a/src/main/java/io/as/modules/daycard/service/DayCardSettleOrderService.java b/src/main/java/io/as/modules/daycard/service/DayCardSettleOrderService.java new file mode 100644 index 0000000..79aa3f0 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/DayCardSettleOrderService.java @@ -0,0 +1,50 @@ +package io.as.modules.daycard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.daycard.entity.DayCardSettleOrderEntity; + +import java.util.List; +import java.util.Map; + +/** + * 天卡分账单 Service + */ +public interface DayCardSettleOrderService extends IService { + + /** + * 分页查询分账单(支持 orderNo/status 筛选) + */ + PageUtils queryPage(Map params); + + /** + * 根据分账单号获取分账单 + */ + DayCardSettleOrderEntity getBySettleNo(String settleNo); + + /** + * 保存分账单 + */ + void saveSettleOrder(DayCardSettleOrderEntity entity); + + /** + * 更新分账单状态 + * + * @param id 分账单ID + * @param status 状态 + * @param errorMessage 错误信息(可空) + */ + void updateStatus(Long id, String status, String errorMessage); + + /** + * 根据订单号获取分账单列表 + */ + List getByOrderNo(String orderNo); + + /** + * 重试失败的分账单 + * + * @return 重试的分账单数量 + */ + int retryFailed(); +} diff --git a/src/main/java/io/as/modules/daycard/service/DayCardSettleRecordService.java b/src/main/java/io/as/modules/daycard/service/DayCardSettleRecordService.java new file mode 100644 index 0000000..1e0461a --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/DayCardSettleRecordService.java @@ -0,0 +1,28 @@ +package io.as.modules.daycard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.daycard.entity.DayCardSettleRecordEntity; + +import java.util.Map; + +/** + * 天卡分账记录 Service + */ +public interface DayCardSettleRecordService extends IService { + + /** + * 分页查询分账记录 + */ + PageUtils queryPage(Map params); + + /** + * 根据ID获取分账记录 + */ + DayCardSettleRecordEntity getById(Long id); + + /** + * 新增分账记录 + */ + void saveRecord(DayCardSettleRecordEntity record); +} diff --git a/src/main/java/io/as/modules/daycard/service/DayCardSettleRuleService.java b/src/main/java/io/as/modules/daycard/service/DayCardSettleRuleService.java new file mode 100644 index 0000000..da816ff --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/DayCardSettleRuleService.java @@ -0,0 +1,50 @@ +package io.as.modules.daycard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.daycard.entity.DayCardSettleRuleEntity; + +import java.util.List; +import java.util.Map; + +/** + * 天卡分账规则 Service + */ +public interface DayCardSettleRuleService extends IService { + + /** + * 分页查询分账规则 + */ + PageUtils queryPage(Map params); + + /** + * 根据ID获取分账规则 + */ + DayCardSettleRuleEntity getById(Long id); + + /** + * 新增分账规则 + */ + void saveRule(DayCardSettleRuleEntity rule); + + /** + * 修改分账规则 + */ + void update(DayCardSettleRuleEntity rule); + + /** + * 批量删除分账规则(软删除) + */ + void delete(Long[] ids); + + /** + * 根据订单信息获取适用的分账规则 + * 优先匹配门店级规则,其次渠道级规则 + * + * @param partnerId 合作伙伴ID + * @param channelOrgId 渠道组织ID + * @param storeOrgId 门店组织ID(可空) + * @return 适用的分账规则列表 + */ + List getRulesByOrder(Long partnerId, Long channelOrgId, Long storeOrgId); +} diff --git a/src/main/java/io/as/modules/daycard/service/OrgSettlementAccountService.java b/src/main/java/io/as/modules/daycard/service/OrgSettlementAccountService.java new file mode 100644 index 0000000..dac3cd1 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/OrgSettlementAccountService.java @@ -0,0 +1,43 @@ +package io.as.modules.daycard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.daycard.entity.OrgSettlementAccountEntity; + +import java.util.Map; + +/** + * 组织分账接收方 Service + */ +public interface OrgSettlementAccountService extends IService { + + /** + * 分页查询(支持 orgId 筛选) + */ + PageUtils queryPage(Map params); + + /** + * 根据 ID 获取 + */ + OrgSettlementAccountEntity getById(Long id); + + /** + * 保存接收方(设置 deleteFlag=UNDEL) + */ + void saveAccount(OrgSettlementAccountEntity account); + + /** + * 批量删除(软删除) + */ + void delete(Long[] ids); + + /** + * 同步接收方到微信(占位:设置 bind_status=ACTIVE) + */ + void syncToWechat(Long id); + + /** + * 根据组织 ID 获取已绑定的接收方 + */ + OrgSettlementAccountEntity getByOrgId(Long orgId); +} diff --git a/src/main/java/io/as/modules/daycard/service/ProfitCalcService.java b/src/main/java/io/as/modules/daycard/service/ProfitCalcService.java new file mode 100644 index 0000000..308cca6 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/ProfitCalcService.java @@ -0,0 +1,34 @@ +package io.as.modules.daycard.service; + +import io.as.modules.daycard.entity.DayCardOrderEntity; +import io.as.modules.daycard.entity.DayCardSettleOrderEntity; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** + * 分润计算 Service + */ +public interface ProfitCalcService { + + /** + * 试算分账结果 + * + * @param partnerId 合作伙伴ID + * @param channelOrgId 渠道组织ID + * @param storeOrgId 门店组织ID + * @param totalAmount 总金额 + * @return Map: { totalAmount, rules: [{ruleId, receiverAccount, receiverName, shareRatio, settleAmount}], totalSettleAmount } + */ + Map calculate(Long partnerId, Long channelOrgId, Long storeOrgId, BigDecimal totalAmount); + + /** + * 匹配规则并计算分账金额,构建分账单列表(不保存,由调用方保存) + * + * @param orderNo 订单号 + * @param order 天卡订单 + * @return 分账单列表 + */ + List buildSettleOrders(String orderNo, DayCardOrderEntity order); +} diff --git a/src/main/java/io/as/modules/daycard/service/ProfitSharingExecuteService.java b/src/main/java/io/as/modules/daycard/service/ProfitSharingExecuteService.java new file mode 100644 index 0000000..4fb9c89 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/ProfitSharingExecuteService.java @@ -0,0 +1,19 @@ +package io.as.modules.daycard.service; + +/** + * 分账执行 Service + */ +public interface ProfitSharingExecuteService { + + /** + * 执行分账(支付成功后调用) + * + * @param orderNo 订单号 + */ + void executeSharing(String orderNo); + + /** + * 重试失败的分账单 + */ + void retryFailed(); +} diff --git a/src/main/java/io/as/modules/daycard/service/ProfitSharingRuleService.java b/src/main/java/io/as/modules/daycard/service/ProfitSharingRuleService.java new file mode 100644 index 0000000..343069c --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/ProfitSharingRuleService.java @@ -0,0 +1,48 @@ +package io.as.modules.daycard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.daycard.entity.ProfitSharingRuleEntity; + +import java.util.Map; + +/** + * 分账规则 Service + */ +public interface ProfitSharingRuleService extends IService { + + /** + * 分页查询分润规则 + */ + PageUtils queryPage(Map params); + + /** + * 获取规则详情(含 items 子表) + */ + ProfitSharingRuleEntity getInfo(Long id); + + /** + * 新增分润规则 + */ + void saveRule(ProfitSharingRuleEntity rule); + + /** + * 修改分润规则 + */ + void updateRule(ProfitSharingRuleEntity rule); + + /** + * 删除分润规则(逻辑删除) + */ + void delete(Long id); + + /** + * 匹配分账规则 + * + * @param storeOrgId 门店组织ID + * @param agentOrgId 代理组织ID + * @param channelOrgId 渠道组织ID + * @return 匹配的分账规则(含规则项),无匹配返回 null + */ + ProfitSharingRuleEntity matchRule(Long storeOrgId, Long agentOrgId, Long channelOrgId); +} diff --git a/src/main/java/io/as/modules/daycard/service/SettleExecuteService.java b/src/main/java/io/as/modules/daycard/service/SettleExecuteService.java new file mode 100644 index 0000000..7303cff --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/SettleExecuteService.java @@ -0,0 +1,31 @@ +package io.as.modules.daycard.service; + +import io.as.modules.daycard.entity.DayCardSettleOrderEntity; + +/** + * 分账执行 Service + */ +public interface SettleExecuteService { + + /** + * 执行分账(支付成功后调用) + *

流程:查询订单 -> 构建分账单 -> 保存 -> 逐条执行

+ * + * @param orderNo 订单号 + */ + void executeSettle(String orderNo); + + /** + * 执行单条分账单 + * + * @param settleOrder 分账单 + */ + void executeSingle(DayCardSettleOrderEntity settleOrder); + + /** + * 重试失败的分账单 + * + * @return 重试的分账单数量 + */ + int retryFailed(); +} diff --git a/src/main/java/io/as/modules/daycard/service/impl/DayCardActivationCodeServiceImpl.java b/src/main/java/io/as/modules/daycard/service/impl/DayCardActivationCodeServiceImpl.java new file mode 100644 index 0000000..6ab041b --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/impl/DayCardActivationCodeServiceImpl.java @@ -0,0 +1,174 @@ +package io.as.modules.daycard.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.exception.ASException; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.dao.DayCardActivationCodeDao; +import io.as.modules.daycard.entity.DayCardActivationCodeEntity; +import io.as.modules.daycard.service.DayCardActivationCodeService; +import org.apache.commons.lang.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Random; + +/** + * 天卡设备激活码服务实现 + *

激活码生成算法复用生产环境 as-partner-backend:5位随机字符(字符集 ABCDEFGHJKMNPQRSTUVWXY23456789,去除易混字符 0/I/L/Z/O/1)

+ */ +@Service("dayCardActivationCodeService") +public class DayCardActivationCodeServiceImpl + extends ServiceImpl + implements DayCardActivationCodeService { + + /** 激活码随机字符集:去除 0/I/L/Z/O/1 等易混字符 */ + private static final String ALLOWED_CHARS = "ABCDEFGHJKMNPQRSTUVWXY23456789"; + private static final int CODE_LENGTH = 5; + private static final int MAX_RETRY = 64; + + @Override + public DayCardActivationCodeEntity getByCode(String code) { + if (StringUtils.isBlank(code)) { + return null; + } + return this.lambdaQuery() + .eq(DayCardActivationCodeEntity::getCode, code.trim()) + .eq(DayCardActivationCodeEntity::getDeleteFlag, DayCardConstants.DELETE_FLAG_UNDEL) + .eq(DayCardActivationCodeEntity::getStatus, "ACTIVE") + .last("LIMIT 1") + .one(); + } + + @Override + public List listByStoreOrgId(Long storeOrgId) { + if (storeOrgId == null) { + return new ArrayList<>(); + } + return this.lambdaQuery() + .eq(DayCardActivationCodeEntity::getStoreOrgId, storeOrgId) + .eq(DayCardActivationCodeEntity::getDeleteFlag, DayCardConstants.DELETE_FLAG_UNDEL) + .orderByDesc(DayCardActivationCodeEntity::getId) + .list(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public DayCardActivationCodeEntity generateForStore(Long storeOrgId, Long channelOrgId, String remark) { + if (storeOrgId == null) { + throw new ASException("门店组织ID不能为空"); + } + // 检查门店是否已有激活码,已有则返回已有的 + DayCardActivationCodeEntity existing = this.lambdaQuery() + .eq(DayCardActivationCodeEntity::getStoreOrgId, storeOrgId) + .eq(DayCardActivationCodeEntity::getDeleteFlag, DayCardConstants.DELETE_FLAG_UNDEL) + .last("LIMIT 1") + .one(); + if (existing != null) { + return existing; + } + + Date now = new Date(); + DayCardActivationCodeEntity entity = new DayCardActivationCodeEntity(); + entity.setCode(generateUniqueCode(null)); + entity.setChannelOrgId(channelOrgId); + entity.setStoreOrgId(storeOrgId); + entity.setStatus("ACTIVE"); + entity.setMaxDeviceCount(0); + entity.setActivatedCount(0); + entity.setRemark(remark); + entity.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + this.save(entity); + return entity; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String regenerateCode(Long id) { + if (id == null) { + throw new ASException("激活码记录ID不能为空"); + } + DayCardActivationCodeEntity entity = getById(id); + if (entity == null) { + throw new ASException("激活码记录不存在"); + } + String newCode = generateUniqueCode(id); + entity.setCode(newCode); + entity.setUpdatedAt(new Date()); + updateById(entity); + return newCode; + } + + @Override + public void updateStatus(Long id, String status) { + if (id == null) { + throw new ASException("激活码记录ID不能为空"); + } + if (!"ACTIVE".equals(status) && !"DISABLED".equals(status)) { + throw new ASException("无效的状态值"); + } + DayCardActivationCodeEntity entity = getById(id); + if (entity == null) { + throw new ASException("激活码记录不存在"); + } + entity.setStatus(status); + entity.setUpdatedAt(new Date()); + updateById(entity); + } + + @Override + public void delete(Long id) { + if (id == null) { + throw new ASException("激活码记录ID不能为空"); + } + DayCardActivationCodeEntity entity = getById(id); + if (entity == null) { + throw new ASException("激活码记录不存在"); + } + entity.setDeleteFlag("DEL"); + entity.setUpdatedAt(new Date()); + updateById(entity); + } + + @Override + public void incrementActivatedCount(Long id) { + if (id == null) { + return; + } + DayCardActivationCodeEntity entity = getById(id); + if (entity == null) { + return; + } + int current = entity.getActivatedCount() != null ? entity.getActivatedCount() : 0; + entity.setActivatedCount(current + 1); + entity.setUpdatedAt(new Date()); + updateById(entity); + } + + /** + * 生成5位随机字符激活码,校验全局唯一(排除自身ID) + */ + private String generateUniqueCode(Long excludeId) { + Random random = new Random(); + for (int attempt = 0; attempt < MAX_RETRY; attempt++) { + StringBuilder sb = new StringBuilder(CODE_LENGTH); + for (int i = 0; i < CODE_LENGTH; i++) { + sb.append(ALLOWED_CHARS.charAt(random.nextInt(ALLOWED_CHARS.length()))); + } + String candidate = sb.toString(); + long dup = this.lambdaQuery() + .eq(DayCardActivationCodeEntity::getCode, candidate) + .eq(DayCardActivationCodeEntity::getDeleteFlag, DayCardConstants.DELETE_FLAG_UNDEL) + .ne(excludeId != null, DayCardActivationCodeEntity::getId, excludeId) + .count(); + if (dup == 0) { + return candidate; + } + } + throw new ASException("生成唯一激活码失败,请重试"); + } +} diff --git a/src/main/java/io/as/modules/daycard/service/impl/DayCardOrderServiceImpl.java b/src/main/java/io/as/modules/daycard/service/impl/DayCardOrderServiceImpl.java new file mode 100644 index 0000000..e412287 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/impl/DayCardOrderServiceImpl.java @@ -0,0 +1,802 @@ +package io.as.modules.daycard.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.common.utils.RedisUtils; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.dao.DayCardOrderDao; +import io.as.modules.daycard.dao.DayCardPoolDao; +import io.as.modules.daycard.entity.DayCardOrderEntity; +import io.as.modules.daycard.entity.DayCardPoolEntity; +import io.as.modules.daycard.entity.DayCardPriceConfigEntity; +import io.as.modules.daycard.service.DayCardOrderService; +import io.as.modules.daycard.service.DayCardPriceConfigService; +import io.as.modules.daycard.service.ProfitSharingExecuteService; +import io.as.modules.daycard.service.SettleExecuteService; +import io.as.modules.device.dao.CardDeviceBindingDao; +import io.as.modules.device.dao.DeviceDao; +import io.as.modules.device.dto.DeviceOrgContext; +import io.as.modules.device.entity.DeviceEntity; +import io.as.modules.device.service.DeviceResolveService; +import io.as.modules.openapi.sign.ApiDeviceSignService; +import io.as.modules.jst.service.JushitingApiService; +import io.as.modules.jst.service.JstActivateCardResult; +import io.as.modules.payment.service.WechatPayService; +import io.as.modules.sys.entity.SysOrgEntity; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * 天卡订单 Service 实现 + */ +@Service("dayCardOrderService") +public class DayCardOrderServiceImpl extends ServiceImpl implements DayCardOrderService { + + private static final Logger logger = LoggerFactory.getLogger(DayCardOrderServiceImpl.class); + + @Autowired + private DayCardPoolDao dayCardPoolDao; + @Autowired + private RedisUtils redisUtils; + @Autowired + private JushitingApiService jushitingApiService; + @Autowired + private DeviceDao deviceDao; + @Autowired + private CardDeviceBindingDao cardDeviceBindingDao; + @Autowired + private DayCardPriceConfigService dayCardPriceConfigService; + @Autowired + private DeviceResolveService deviceResolveService; + @Autowired + @Lazy + private SettleExecuteService settleExecuteService; + @Autowired + @Lazy + private ProfitSharingExecuteService profitSharingExecuteService; + @Autowired + @Lazy + private WechatPayService wechatPayService; + + /** 自注入以确保 @Async 通过 Spring 代理生效(避免同类自调用绕过 AOP 代理) */ + @Autowired + @Lazy + private DayCardOrderService selfProxy; + + @Autowired + private ApiDeviceSignService apiDeviceSignService; + + @Value("${renren.daycard.order-timeout-minutes:15}") + private int orderTimeoutMinutes; + + @Value("${renren.daycard.cancel-batch-size:200}") + private int cancelBatchSize; + + @Override + public PageUtils queryPage(Map params, Long partnerId) { + IPage page = new Query().getPage(params, "created_at", false); + + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + if (partnerId != null) { + wrapper.eq("partner_id", partnerId); + } + String orderNo = (String) params.get("orderNo"); + if (StringUtils.isNotBlank(orderNo)) { + wrapper.eq("order_no", orderNo); + } + String status = (String) params.get("status"); + if (StringUtils.isNotBlank(status)) { + wrapper.eq("status", status); + } + + IPage result = baseMapper.selectPage(page, wrapper); + return new PageUtils(result); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public DayCardOrderEntity createOrder(Long deviceId) { + if (deviceId == null) { + throw new ASException("设备ID不能为空"); + } + + // 0. 查重:已有有效权益则拒绝创建订单,避免卡密浪费 + DayCardPoolEntity existingBenefits = getDeviceBenefits(deviceId); + if (existingBenefits != null) { + throw new ASException("该设备已有有效天卡权益,无需重复购买"); + } + + // 1. Redis防重:用 deviceId 作为锁键 + String lockKey = DayCardConstants.REDIS_DEVICE_ORDER_PREFIX + deviceId; + long ttlSeconds = orderTimeoutMinutes * 60L; + boolean locked = redisUtils.setIfAbsent(lockKey, "1", ttlSeconds); + if (!locked) { + throw new ASException("该设备已有进行中的订单,请稍后再试"); + } + + try { + // 2. 解析设备组织链路(设备 → 门店 → 渠道 → 代理商) + DeviceOrgContext orgContext = deviceResolveService.resolveDevice(deviceId); + DeviceEntity device = orgContext.getDevice(); + Long storeOrgId = device.getStoreOrgId(); + Long channelOrgId = orgContext.getChannelOrg() != null ? orgContext.getChannelOrg().getId() : null; + Long agentOrgId = orgContext.getAgentOrg() != null ? orgContext.getAgentOrg().getId() : null; + + // 3. 组织链路快照 + SysOrgEntity storeOrg = orgContext.getStoreOrg(); + String orgPathSnapshot = storeOrg != null ? storeOrg.getMpath() : null; + + // 4. 3 层定价:门店 > 渠道 > 平台默认 + DayCardPriceConfigEntity priceConfig = dayCardPriceConfigService.getEffectivePrice(channelOrgId, storeOrgId); + if (priceConfig == null) { + throw new ASException("未配置定价策略,请先在管理后台配置天卡价格"); + } + BigDecimal amount = priceConfig.getPrice(); + + // 5. 生成订单号 + String orderNo = generateOrderNo(); + + // 6. 插入订单(partnerId 置 null) + DayCardOrderEntity order = new DayCardOrderEntity(); + order.setOrderNo(orderNo); + order.setPartnerId(null); + order.setDeviceId(device.getId()); + order.setStoreOrgId(storeOrgId); + order.setChannelOrgId(channelOrgId); + order.setAmount(amount); + order.setStatus(DayCardConstants.ORDER_STATUS_CREATED); + order.setPayChannel("MOCK"); + order.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL); + Date now = new Date(); + order.setCreatedAt(now); + order.setUpdatedAt(now); + Date expireTime = new Date(now.getTime() + ttlSeconds * 1000); + order.setExpireTime(expireTime); + order.setDeviceSn(device.getDeviceSn()); + order.setMac(device.getMac()); + order.setAgentOrgId(agentOrgId); + order.setOrgPathSnapshot(orgPathSnapshot); + baseMapper.insert(order); + + // 7. 微信 Native 支付下单 + if (wechatPayService.isConfigured()) { + try { + String codeUrl = wechatPayService.createNativePay(orderNo, "天卡订单", amount); + order.setCodeUrl(codeUrl); + order.setPayChannel("WECHAT"); + UpdateWrapper payUpdate = new UpdateWrapper<>(); + payUpdate.eq("id", order.getId()) + .set("code_url", codeUrl) + .set("pay_channel", "WECHAT"); + baseMapper.update(null, payUpdate); + } catch (Exception e) { + logger.warn("微信下单失败,回退到MOCK模式: orderNo={}", orderNo, e); + order.setPayChannel("MOCK"); + order.setCodeUrl("mock://pay?orderNo=" + orderNo); + UpdateWrapper payUpdate = new UpdateWrapper<>(); + payUpdate.eq("id", order.getId()) + .set("code_url", order.getCodeUrl()) + .set("pay_channel", "MOCK"); + baseMapper.update(null, payUpdate); + } + } else { + order.setPayChannel("MOCK"); + order.setCodeUrl("mock://pay?orderNo=" + orderNo); + UpdateWrapper payUpdate = new UpdateWrapper<>(); + payUpdate.eq("id", order.getId()) + .set("code_url", order.getCodeUrl()) + .set("pay_channel", "MOCK"); + baseMapper.update(null, payUpdate); + } + + // 8. SKIP LOCKED 抢卡(通用池,不按 partner 过滤) + DayCardPoolEntity pool = dayCardPoolDao.selectOneForUpdateSkipLocked(); + if (pool == null) { + logger.warn("创建天卡订单失败:天卡池库存不足, orderNo={}", orderNo); + throw new ASException("天卡库存不足,请补充库存后再试"); + } + + // 9. 乐观锁更新卡状态 UNUSED -> RESERVED + UpdateWrapper poolUpdate = new UpdateWrapper<>(); + poolUpdate.eq("id", pool.getId()) + .in("status", DayCardConstants.POOL_STATUS_UNUSED, DayCardConstants.POOL_STATUS_REUSABLE) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .set("status", DayCardConstants.POOL_STATUS_RESERVED) + .set("order_no", orderNo) + .set("device_id", device.getId()) + .set("updated_at", now); + int updated = dayCardPoolDao.update(null, poolUpdate); + if (updated == 0) { + logger.warn("抢卡竞争失败: poolId={}, orderNo={}", pool.getId(), orderNo); + throw new ASException("天卡库存不足,请稍后再试"); + } + + order.setCardNo(pool.getCardCode()); + return order; + } catch (ASException e) { + redisUtils.delete(lockKey); + throw e; + } catch (Exception e) { + redisUtils.delete(lockKey); + throw new ASException("创建天卡订单失败: " + e.getMessage(), e); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public DayCardOrderEntity createOrder(String mac, String deviceSn, String deviceUid, Long partnerId) { + // 1. Redis防重:用 mac 或 deviceSn 或 deviceUid 作为锁键 + String lockKey = DayCardConstants.REDIS_DEVICE_ORDER_PREFIX; + if (StringUtils.isNotBlank(mac)) { + lockKey = lockKey + mac; + } else if (StringUtils.isNotBlank(deviceSn)) { + lockKey = lockKey + deviceSn; + } else if (StringUtils.isNotBlank(deviceUid)) { + lockKey = lockKey + deviceUid; + } else { + throw new ASException("设备标识不能全为空"); + } + long ttlSeconds = orderTimeoutMinutes * 60L; + boolean locked = redisUtils.setIfAbsent(lockKey, "1", ttlSeconds); + if (!locked) { + throw new ASException("该设备已有进行中的订单,请稍后再试"); + } + + try { + // 2. 通过 DeviceResolveService 解析设备组织链路(设备 → 门店 → 渠道 → 代理商) + DeviceOrgContext orgContext = deviceResolveService.resolveDevice(mac, deviceSn, deviceUid); + DeviceEntity device = orgContext.getDevice(); + Long storeOrgId = device.getStoreOrgId(); + // 查重:已有有效权益则拒绝创建订单,避免卡密浪费 + DayCardPoolEntity existingBenefits = getDeviceBenefits(device.getId()); + if (existingBenefits != null) { + throw new ASException("该设备已有有效天卡权益,无需重复购买"); + } + Long channelOrgId = orgContext.getChannelOrg() != null ? orgContext.getChannelOrg().getId() : null; + Long agentOrgId = orgContext.getAgentOrg() != null ? orgContext.getAgentOrg().getId() : null; + + // 3. 构建组织链路快照(使用门店的 mpath) + SysOrgEntity storeOrg = orgContext.getStoreOrg(); + String orgPathSnapshot = storeOrg != null ? storeOrg.getMpath() : null; + + // 4. 3 层定价:门店 > 渠道 > 平台默认 + DayCardPriceConfigEntity priceConfig = dayCardPriceConfigService.getEffectivePrice(channelOrgId, storeOrgId); + if (priceConfig == null) { + throw new ASException("未配置定价策略,请先在管理后台配置天卡价格"); + } + BigDecimal amount = priceConfig.getPrice(); + + // 5. 生成订单号(yyyyMMddHHmmss + 6位随机数) + String orderNo = generateOrderNo(); + + // 6. 插入订单 + DayCardOrderEntity order = new DayCardOrderEntity(); + order.setOrderNo(orderNo); + order.setPartnerId(partnerId); + order.setDeviceId(device.getId()); + order.setStoreOrgId(storeOrgId); + order.setChannelOrgId(channelOrgId); + order.setAmount(amount); + order.setStatus(DayCardConstants.ORDER_STATUS_CREATED); + order.setPayChannel("MOCK"); + order.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL); + Date now = new Date(); + order.setCreatedAt(now); + order.setUpdatedAt(now); + // 订单过期时间 + Date expireTime = new Date(now.getTime() + ttlSeconds * 1000); + order.setExpireTime(expireTime); + // 额外设置设备冗余信息 + order.setDeviceSn(device.getDeviceSn()); + order.setMac(device.getMac()); + order.setAgentOrgId(agentOrgId); + order.setOrgPathSnapshot(orgPathSnapshot); + baseMapper.insert(order); + + // 6.5 微信 Native 支付下单 + if (wechatPayService.isConfigured()) { + try { + String codeUrl = wechatPayService.createNativePay(orderNo, "天卡订单", amount); + order.setCodeUrl(codeUrl); + order.setPayChannel("WECHAT"); + UpdateWrapper payUpdate = new UpdateWrapper<>(); + payUpdate.eq("id", order.getId()) + .set("code_url", codeUrl) + .set("pay_channel", "WECHAT"); + baseMapper.update(null, payUpdate); + } catch (Exception e) { + logger.warn("微信下单失败,回退到MOCK模式: orderNo={}", orderNo, e); + order.setPayChannel("MOCK"); + order.setCodeUrl("mock://pay?orderNo=" + orderNo); + UpdateWrapper payUpdate = new UpdateWrapper<>(); + payUpdate.eq("id", order.getId()) + .set("code_url", order.getCodeUrl()) + .set("pay_channel", "MOCK"); + baseMapper.update(null, payUpdate); + } + } else { + // 开发模式:使用 Mock 支付 + order.setPayChannel("MOCK"); + order.setCodeUrl("mock://pay?orderNo=" + orderNo); + UpdateWrapper payUpdate = new UpdateWrapper<>(); + payUpdate.eq("id", order.getId()) + .set("code_url", order.getCodeUrl()) + .set("pay_channel", "MOCK"); + baseMapper.update(null, payUpdate); + } + + // 7. SKIP LOCKED 抢卡 + DayCardPoolEntity pool = dayCardPoolDao.selectOneForUpdateSkipLocked(); + if (pool == null) { + logger.warn("创建天卡订单失败:合作商[{}]天卡池库存不足, orderNo={}", partnerId, orderNo); + throw new ASException("天卡库存不足,请补充库存后再试"); + } + + // 8. 乐观锁更新卡状态 UNUSED -> RESERVED + UpdateWrapper poolUpdate = new UpdateWrapper<>(); + poolUpdate.eq("id", pool.getId()) + .in("status", DayCardConstants.POOL_STATUS_UNUSED, DayCardConstants.POOL_STATUS_REUSABLE) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .set("status", DayCardConstants.POOL_STATUS_RESERVED) + .set("order_no", orderNo) + .set("device_id", device.getId()) + .set("updated_at", now); + int updated = dayCardPoolDao.update(null, poolUpdate); + if (updated == 0) { + // 卡已被其他线程抢占 + logger.warn("抢卡竞争失败: poolId={}, orderNo={}", pool.getId(), orderNo); + throw new ASException("天卡库存不足,请稍后再试"); + } + + // 更新订单上的卡号信息 + order.setCardNo(pool.getCardCode()); + + return order; + } catch (ASException e) { + // 业务异常时清理Redis锁,允许用户重试 + redisUtils.delete(lockKey); + throw e; + } catch (Exception e) { + redisUtils.delete(lockKey); + throw new ASException("创建天卡订单失败: " + e.getMessage(), e); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void payCallback(String orderNo, String payNo, BigDecimal payAmount) { + // 1. 幂等检查:查询订单 + DayCardOrderEntity order = getByOrderNo(orderNo); + if (order == null) { + throw new ASException("订单不存在: " + orderNo); + } + // 已支付则幂等返回 + if (DayCardConstants.ORDER_STATUS_PAID.equals(order.getStatus())) { + logger.info("支付回调幂等返回: orderNo={}, 已为PAID状态", orderNo); + return; + } + // 非CREATED/PAYING状态不允许回调 + if (!DayCardConstants.ORDER_STATUS_CREATED.equals(order.getStatus()) + && !DayCardConstants.ORDER_STATUS_PAYING.equals(order.getStatus())) { + throw new ASException("订单状态不允许支付回调: " + order.getStatus()); + } + + // 2. 乐观锁更新订单为PAID + UpdateWrapper orderUpdate = new UpdateWrapper<>(); + orderUpdate.eq("order_no", orderNo) + .in("status", DayCardConstants.ORDER_STATUS_CREATED, DayCardConstants.ORDER_STATUS_PAYING) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .set("status", DayCardConstants.ORDER_STATUS_PAID) + .set("pay_no", payNo) + .set("pay_amount", payAmount) + .set("pay_time", new Date()) + .set("updated_at", new Date()); + int updated = baseMapper.update(null, orderUpdate); + if (updated == 0) { + throw new ASException("订单状态已变更,支付回调失败: " + orderNo); + } + + // 3. 更新卡状态 RESERVED -> USED,同时设置初始 expire_at(24小时兜底,异步激活成功后覆盖) + Date now = new Date(); + Date defaultExpireAt = new Date(now.getTime() + 24 * 60 * 60 * 1000L); + UpdateWrapper poolUpdate = new UpdateWrapper<>(); + poolUpdate.eq("order_no", orderNo) + .eq("status", DayCardConstants.POOL_STATUS_RESERVED) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .set("status", DayCardConstants.POOL_STATUS_USED) + .set("activate_status", DayCardConstants.ACTIVATE_STATUS_PENDING) + .set("used_at", now) + .set("expire_at", defaultExpireAt) + .set("updated_at", now); + dayCardPoolDao.update(null, poolUpdate); + + // 4. 异步激活(调用jst服务)——通过 selfProxy 调用确保 @Async 生效 + selfProxy.asyncActivateCard(order); + + // 5. 清理Redis防重锁 + String lockKey = DayCardConstants.REDIS_DEVICE_ORDER_PREFIX + order.getDeviceId(); + redisUtils.delete(lockKey); + // 兼容新下单接口(锁键使用mac/deviceSn/deviceUid) + if (StringUtils.isNotBlank(order.getMac())) { + redisUtils.delete(DayCardConstants.REDIS_DEVICE_ORDER_PREFIX + order.getMac()); + } + if (StringUtils.isNotBlank(order.getDeviceSn())) { + redisUtils.delete(DayCardConstants.REDIS_DEVICE_ORDER_PREFIX + order.getDeviceSn()); + } + + // 6. 触发分账(异步) + try { + profitSharingExecuteService.executeSharing(orderNo); + } catch (Exception e) { + logger.error("触发分账失败: orderNo={}", orderNo, e); + } + } + + /** + * 异步激活天卡:调用橘视厅接口 + *

通过 {@link #selfProxy} 调用以确保 Spring AOP 代理生效

+ *

激活状态追踪:PENDING → SUCCESS/FAILED

+ */ + @Async("thirdPartyActivateExecutor") + @Override + public void asyncActivateCard(DayCardOrderEntity order) { + try { + // 查询绑定的天卡 + QueryWrapper poolWrapper = new QueryWrapper<>(); + poolWrapper.eq("order_no", order.getOrderNo()) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + DayCardPoolEntity pool = dayCardPoolDao.selectOne(poolWrapper); + if (pool == null) { + logger.warn("异步激活天卡失败:未找到订单关联的卡, orderNo={}", order.getOrderNo()); + return; + } + + // 查询设备 + DeviceEntity device = deviceDao.selectById(order.getDeviceId()); + if (device == null) { + updateActivateStatus(pool.getId(), DayCardConstants.ACTIVATE_STATUS_FAILED, "设备不存在: " + order.getDeviceId(), null); + logger.warn("异步激活天卡失败:设备不存在, deviceId={}", order.getDeviceId()); + return; + } + + // 1. 调用橘视厅免密登录获取Token + String thirdCardRef = pool.getThirdCardNo(); + if (StringUtils.isBlank(thirdCardRef)) { + updateActivateStatus(pool.getId(), DayCardConstants.ACTIVATE_STATUS_FAILED, "卡的第三方引用为空", null); + logger.warn("异步激活天卡失败:卡的第三方引用为空, poolId={}", pool.getId()); + return; + } + + Map tokenResult = jushitingApiService.getToken(device, thirdCardRef); + if (tokenResult == null || StringUtils.isBlank(tokenResult.get("token"))) { + updateActivateStatus(pool.getId(), DayCardConstants.ACTIVATE_STATUS_FAILED, "获取Token失败", null); + logger.warn("异步激活天卡失败:获取Token失败, orderNo={}", order.getOrderNo()); + return; + } + + // 2. 调用卡密激活 + String memberCode = tokenResult.get("memberCode"); + String token = tokenResult.get("token"); + JstActivateCardResult activateResult = jushitingApiService.activateCard( + order.getDeviceId(), pool.getCardCode(), memberCode, token); + + if (!activateResult.isSuccess()) { + String errorMsg = activateResult.getErrorMessage() != null ? activateResult.getErrorMessage() : "卡密激活返回失败"; + updateActivateStatus(pool.getId(), DayCardConstants.ACTIVATE_STATUS_FAILED, errorMsg, null); + logger.error("异步激活天卡失败:卡密激活返回失败, orderNo={}, cardCode={}", order.getOrderNo(), pool.getCardCode()); + return; + } + + // 3. 确定卡有效期 + Date expireAt = null; + // 优先使用激活接口返回的 expireTime + if (StringUtils.isNotBlank(activateResult.getExpireTime())) { + expireAt = parseExpireTime(activateResult.getExpireTime()); + } + // 激活接口未返回到期时间,主动查询 VIP 到期时间 + if (expireAt == null) { + String vipExpireTime = jushitingApiService.queryVipExpireTime(order.getDeviceId(), thirdCardRef); + if (StringUtils.isNotBlank(vipExpireTime)) { + expireAt = parseExpireTime(vipExpireTime); + } + } + // 兜底:默认24小时有效期 + if (expireAt == null) { + expireAt = new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000L); + logger.warn("橘视厅未返回到期时间,使用默认24小时: orderNo={}", order.getOrderNo()); + } + + // 4. 更新 day_card_pool:写入 expire_at + 激活成功状态 + UpdateWrapper poolUpdate = new UpdateWrapper<>(); + poolUpdate.eq("id", pool.getId()) + .set("expire_at", expireAt) + .set("activate_status", DayCardConstants.ACTIVATE_STATUS_SUCCESS) + .set("activated_at", new Date()) + .set("activate_error", null) + .set("updated_at", new Date()); + dayCardPoolDao.update(null, poolUpdate); + + logger.info("异步激活天卡完成: orderNo={}, cardCode={}, expireAt={}", order.getOrderNo(), pool.getCardCode(), expireAt); + } catch (Exception e) { + logger.error("异步激活天卡异常: orderNo={}", order.getOrderNo(), e); + // 尝试更新激活失败状态 + try { + QueryWrapper poolWrapper = new QueryWrapper<>(); + poolWrapper.eq("order_no", order.getOrderNo()) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + DayCardPoolEntity pool = dayCardPoolDao.selectOne(poolWrapper); + if (pool != null) { + String errorMsg = e.getMessage(); + if (errorMsg != null && errorMsg.length() > 900) { + errorMsg = errorMsg.substring(0, 900); + } + updateActivateStatus(pool.getId(), DayCardConstants.ACTIVATE_STATUS_FAILED, errorMsg, null); + } + } catch (Exception ex) { + logger.error("更新激活失败状态异常: orderNo={}", order.getOrderNo(), ex); + } + } + } + + /** + * 更新天卡激活状态 + */ + private void updateActivateStatus(Long poolId, String status, String error, Date activatedAt) { + UpdateWrapper update = new UpdateWrapper<>(); + update.eq("id", poolId) + .set("activate_status", status) + .set("activate_error", error) + .set("updated_at", new Date()); + if (activatedAt != null) { + update.set("activated_at", activatedAt); + } + dayCardPoolDao.update(null, update); + } + + /** + * 解析橘视厅返回的到期时间字符串(格式如 "2027-04-24 16:48:58") + */ + private Date parseExpireTime(String timeStr) { + if (timeStr == null || timeStr.trim().isEmpty()) { + return null; + } + try { + java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + sdf.setTimeZone(java.util.TimeZone.getTimeZone("Asia/Shanghai")); + return sdf.parse(timeStr.trim()); + } catch (Exception e) { + logger.warn("解析到期时间失败: {}", timeStr); + return null; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int cancelTimeoutOrders() { + // 查询超时订单(状态CREATED, expire_time < now, LIMIT batch_size) + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("status", DayCardConstants.ORDER_STATUS_CREATED) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .lt("expire_time", new Date()) + .last("LIMIT " + cancelBatchSize); + List timeoutOrders = baseMapper.selectList(wrapper); + + int cancelled = 0; + for (DayCardOrderEntity order : timeoutOrders) { + try { + cancelSingleOrder(order); + cancelled++; + } catch (Exception e) { + logger.error("取消超时订单失败: orderNo={}", order.getOrderNo(), e); + } + } + + if (cancelled > 0) { + logger.info("本次取消超时天卡订单 {} 笔", cancelled); + } + return cancelled; + } + + /** + * 取消单个超时订单:释放卡 + 更新订单CANCELLED + */ + private void cancelSingleOrder(DayCardOrderEntity order) { + Date now = new Date(); + + // 微信支付订单需调用微信关单API + if ("WECHAT".equals(order.getPayChannel())) { + try { + wechatPayService.closeOrder(order.getOrderNo()); + } catch (Exception e) { + logger.warn("微信关单失败,继续本地取消: orderNo={}", order.getOrderNo(), e); + } + } + + // 释放卡:RESERVED -> UNUSED + UpdateWrapper poolUpdate = new UpdateWrapper<>(); + poolUpdate.eq("order_no", order.getOrderNo()) + .eq("status", DayCardConstants.POOL_STATUS_RESERVED) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .set("status", DayCardConstants.POOL_STATUS_UNUSED) + .set("order_no", null) + .set("device_id", null) + .set("updated_at", now); + dayCardPoolDao.update(null, poolUpdate); + + // 更新订单为CANCELLED + UpdateWrapper orderUpdate = new UpdateWrapper<>(); + orderUpdate.eq("id", order.getId()) + .eq("status", DayCardConstants.ORDER_STATUS_CREATED) + .set("status", DayCardConstants.ORDER_STATUS_CANCELLED) + .set("cancel_time", now) + .set("updated_at", now); + baseMapper.update(null, orderUpdate); + + // 清理Redis防重锁 + String lockKey = DayCardConstants.REDIS_DEVICE_ORDER_PREFIX + order.getDeviceId(); + redisUtils.delete(lockKey); + // 兼容新下单接口(锁键使用mac/deviceSn/deviceUid) + if (StringUtils.isNotBlank(order.getMac())) { + redisUtils.delete(DayCardConstants.REDIS_DEVICE_ORDER_PREFIX + order.getMac()); + } + if (StringUtils.isNotBlank(order.getDeviceSn())) { + redisUtils.delete(DayCardConstants.REDIS_DEVICE_ORDER_PREFIX + order.getDeviceSn()); + } + } + + @Override + public DayCardOrderEntity getByOrderNo(String orderNo) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("order_no", orderNo) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + return baseMapper.selectOne(wrapper); + } + + @Override + public DayCardPoolEntity getDeviceBenefits(Long deviceId) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("device_id", deviceId) + .eq("status", DayCardConstants.POOL_STATUS_USED) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .isNotNull("expire_at") + .gt("expire_at", new Date()) + .orderByDesc("used_at") + .last("LIMIT 1"); + return dayCardPoolDao.selectOne(wrapper); + } + + @Override + public DayCardPriceConfigEntity getDevicePrice(Long deviceId) { + DeviceOrgContext orgContext = deviceResolveService.resolveDevice(deviceId); + DeviceEntity device = orgContext.getDevice(); + Long storeOrgId = device.getStoreOrgId(); + Long channelOrgId = orgContext.getChannelOrg() != null ? orgContext.getChannelOrg().getId() : null; + return dayCardPriceConfigService.getEffectivePrice(channelOrgId, storeOrgId); + } + + @Override + public void unbindDevice(Long deviceId) { + if (deviceId == null) { + throw new ASException("设备ID不能为空"); + } + DayCardPoolEntity pool = getDeviceBenefits(deviceId); + if (pool == null) { + throw new ASException("设备没有有效的天卡权益,无需解绑"); + } + UpdateWrapper update = new UpdateWrapper<>(); + update.eq("id", pool.getId()) + .eq("status", DayCardConstants.POOL_STATUS_USED) + .set("status", DayCardConstants.POOL_STATUS_REUSABLE) + .set("device_id", null) + .set("updated_at", new Date()); + int updated = dayCardPoolDao.update(null, update); + if (updated == 0) { + throw new ASException("解绑失败,卡状态可能已变更"); + } + logger.info("设备解绑成功: deviceId={}, poolId={}, cardCode={}, expireAt={}", + deviceId, pool.getId(), pool.getCardCode(), pool.getExpireAt()); + } + + @Override + public void unbindDeviceOrg(Long deviceId) { + if (deviceId == null) { + throw new ASException("设备ID不能为空"); + } + Date now = new Date(); + // 1. 释放设备当前天卡权益(若有):USED -> REUSABLE,清卡上的 device_id + DayCardPoolEntity pool = getDeviceBenefits(deviceId); + if (pool != null) { + UpdateWrapper poolUpdate = new UpdateWrapper<>(); + poolUpdate.eq("id", pool.getId()) + .eq("status", DayCardConstants.POOL_STATUS_USED) + .set("status", DayCardConstants.POOL_STATUS_REUSABLE) + .set("device_id", null) + .set("updated_at", now); + dayCardPoolDao.update(null, poolUpdate); + logger.info("设备组织解绑-释放天卡权益: deviceId={}, poolId={}, cardCode={}", + deviceId, pool.getId(), pool.getCardCode()); + } + // 2. 清除设备的门店组织关联(storeOrgId),设备可在新场所用新激活码重新绑定 + UpdateWrapper deviceUpdate = new UpdateWrapper<>(); + deviceUpdate.eq("id", deviceId) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .set("store_org_id", null) + .set("updated_at", now); + int updated = deviceDao.update(null, deviceUpdate); + if (updated == 0) { + throw new ASException("设备不存在或解绑失败: " + deviceId); + } + // 3. 作废当前 sign,App 端旧 sign 立即失效,被迫回到激活码输入页(逆向 init) + apiDeviceSignService.invalidate(deviceId); + logger.info("设备组织解绑成功: deviceId={}", deviceId); + } + + @Override + public int retryFailedActivations() { + // 查询激活失败的卡(status=USED, activate_status=FAILED) + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("status", DayCardConstants.POOL_STATUS_USED) + .eq("activate_status", DayCardConstants.ACTIVATE_STATUS_FAILED) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .last("LIMIT " + cancelBatchSize); + List failedPools = dayCardPoolDao.selectList(wrapper); + + int retried = 0; + for (DayCardPoolEntity pool : failedPools) { + try { + // 查询关联订单 + if (StringUtils.isBlank(pool.getOrderNo())) { + continue; + } + DayCardOrderEntity order = getByOrderNo(pool.getOrderNo()); + if (order == null) { + continue; + } + + // 重置激活状态为 PENDING + updateActivateStatus(pool.getId(), DayCardConstants.ACTIVATE_STATUS_PENDING, null, null); + + // 通过 selfProxy 调用确保 @Async 生效 + selfProxy.asyncActivateCard(order); + retried++; + } catch (Exception e) { + logger.error("重试激活天卡失败: poolId={}, orderNo={}", pool.getId(), pool.getOrderNo(), e); + } + } + + if (retried > 0) { + logger.info("本次重试激活失败天卡 {} 张", retried); + } + return retried; + } + + /** + * 生成订单号:yyyyMMddHHmmss + 6位随机数 + */ + private String generateOrderNo() { + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); + String timestamp = sdf.format(new Date()); + int random = ThreadLocalRandom.current().nextInt(100000, 1000000); + return timestamp + random; + } +} diff --git a/src/main/java/io/as/modules/daycard/service/impl/DayCardPoolServiceImpl.java b/src/main/java/io/as/modules/daycard/service/impl/DayCardPoolServiceImpl.java new file mode 100644 index 0000000..ca9e7d0 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/impl/DayCardPoolServiceImpl.java @@ -0,0 +1,207 @@ +package io.as.modules.daycard.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.dao.DayCardImportBatchDao; +import io.as.modules.daycard.dao.DayCardPoolDao; +import io.as.modules.daycard.entity.DayCardImportBatchEntity; +import io.as.modules.daycard.entity.DayCardPoolEntity; +import io.as.modules.daycard.service.DayCardPoolService; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; + +/** + * 天卡池 Service 实现 + */ +@Service("dayCardPoolService") +public class DayCardPoolServiceImpl extends ServiceImpl implements DayCardPoolService { + + private static final Logger logger = LoggerFactory.getLogger(DayCardPoolServiceImpl.class); + + private static final DateTimeFormatter CARD_NO_DATE = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final DateTimeFormatter THIRD_PARTY_REF_HHMMSS = DateTimeFormatter.ofPattern("HHmmss"); + private static final String THIRD_CARD_NO_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + private static final int THIRD_CARD_NO_LENGTH = 24; + private static final String THIRD_CARD_NO_PREFIX = "D"; + private static final SecureRandom THIRD_CARD_NO_RANDOM = new SecureRandom(); + private static final int MAX_RETRY = 10; + + @Autowired + private DayCardImportBatchDao dayCardImportBatchDao; + + @Override + public PageUtils queryPage(Map params, Long partnerId) { + IPage page = new Query().getPage(params, "created_at", false); + + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + if (partnerId != null) { + wrapper.eq("partner_id", partnerId); + } + String status = (String) params.get("status"); + if (StringUtils.isNotBlank(status)) { + wrapper.eq("status", status); + } + String cardCode = (String) params.get("cardCode"); + if (StringUtils.isNotBlank(cardCode)) { + wrapper.eq("card_code", cardCode); + } + String orderNo = (String) params.get("orderNo"); + if (StringUtils.isNotBlank(orderNo)) { + wrapper.eq("order_no", orderNo); + } + + IPage result = baseMapper.selectPage(page, wrapper); + return new PageUtils(result); + } + + @Override + public int getAvailableCount(Long partnerId) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .eq("status", DayCardConstants.POOL_STATUS_UNUSED); + if (partnerId != null) { + wrapper.eq("partner_id", partnerId); + } + return baseMapper.selectCount(wrapper); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int importCards(List cardCodes, Long partnerId) { + if (cardCodes == null || cardCodes.isEmpty()) { + return 0; + } + Date now = new Date(); + String batchNo = "DC" + now.getTime(); + int successCount = 0; + + // 创建导入批次 + DayCardImportBatchEntity batch = new DayCardImportBatchEntity(); + batch.setBatchNo(batchNo); + batch.setPartnerId(partnerId); + batch.setStatus(DayCardConstants.BATCH_STATUS_PROCESSING); + batch.setTotalCount(cardCodes.size()); + batch.setSuccessCount(0); + batch.setFailCount(0); + batch.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL); + batch.setCreatedAt(now); + batch.setUpdatedAt(now); + dayCardImportBatchDao.insert(batch); + + Set thirdCardNoReserved = new HashSet<>(); + List toInsert = new ArrayList<>(); + + for (String code : cardCodes) { + String trimmed = code == null ? "" : code.trim(); + if (trimmed.isEmpty()) { + continue; + } + // 查重:同一合作商下 cardCode 不重复 + long cnt = this.lambdaQuery() + .eq(DayCardPoolEntity::getCardCode, trimmed) + .eq(DayCardPoolEntity::getDeleteFlag, DayCardConstants.DELETE_FLAG_UNDEL) + .count(); + if (cnt > 0) { + logger.warn("导入天卡跳过重复卡密: cardCode={}", trimmed); + continue; + } + + DayCardPoolEntity pool = new DayCardPoolEntity(); + pool.setCardCode(trimmed); + pool.setThirdPartyRef(generateUniqueThirdPartyRef()); + pool.setThirdCardNo(generateThirdCardNo(thirdCardNoReserved)); + pool.setCardType(DayCardConstants.CARD_TYPE_DAY); + pool.setPartnerId(partnerId); + pool.setStatus(DayCardConstants.POOL_STATUS_UNUSED); + pool.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL); + pool.setCreatedAt(now); + pool.setUpdatedAt(now); + toInsert.add(pool); + successCount++; + } + + if (!toInsert.isEmpty()) { + // MyBatis-Plus saveBatch + this.saveBatch(toInsert); + } + + // 更新批次状态 + batch.setStatus(DayCardConstants.BATCH_STATUS_SUCCEEDED); + batch.setSuccessCount(successCount); + batch.setFailCount(cardCodes.size() - successCount); + batch.setUpdatedAt(new Date()); + dayCardImportBatchDao.updateById(batch); + + logger.info("天卡导入完成: batchNo={}, total={}, success={}", batchNo, cardCodes.size(), successCount); + return successCount; + } + + @Override + public String generateUniqueThirdPartyRef() { + ThreadLocalRandom rnd = ThreadLocalRandom.current(); + for (int attempt = 0; attempt < MAX_RETRY; attempt++) { + LocalDateTime n = LocalDateTime.now(); + String ref = n.format(CARD_NO_DATE) + "-" + n.format(THIRD_PARTY_REF_HHMMSS) + "-" + + String.format("%04d", rnd.nextInt(10000)); + long cnt = this.lambdaQuery() + .eq(DayCardPoolEntity::getThirdPartyRef, ref) + .eq(DayCardPoolEntity::getDeleteFlag, DayCardConstants.DELETE_FLAG_UNDEL) + .count(); + if (cnt == 0) { + return ref; + } + } + throw new RuntimeException("生成第三方引用号失败,请稍后重试"); + } + + @Override + public String generateThirdCardNo() { + return generateThirdCardNo(null); + } + + private String generateThirdCardNo(Set inFlightReserved) { + int alphaLen = THIRD_CARD_NO_ALPHABET.length(); + for (int attempt = 0; attempt < MAX_RETRY; attempt++) { + StringBuilder sb = new StringBuilder(THIRD_CARD_NO_PREFIX.length() + THIRD_CARD_NO_LENGTH); + sb.append(THIRD_CARD_NO_PREFIX); + for (int i = 0; i < THIRD_CARD_NO_LENGTH; i++) { + sb.append(THIRD_CARD_NO_ALPHABET.charAt(THIRD_CARD_NO_RANDOM.nextInt(alphaLen))); + } + String third = sb.toString(); + if (inFlightReserved != null && inFlightReserved.contains(third)) { + continue; + } + long cnt = this.lambdaQuery() + .eq(DayCardPoolEntity::getThirdCardNo, third) + .eq(DayCardPoolEntity::getDeleteFlag, DayCardConstants.DELETE_FLAG_UNDEL) + .count(); + if (cnt == 0) { + if (inFlightReserved != null) { + inFlightReserved.add(third); + } + return third; + } + } + throw new RuntimeException("生成第三方卡号失败,请稍后重试"); + } +} diff --git a/src/main/java/io/as/modules/daycard/service/impl/DayCardPriceConfigServiceImpl.java b/src/main/java/io/as/modules/daycard/service/impl/DayCardPriceConfigServiceImpl.java new file mode 100644 index 0000000..6cf2ece --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/impl/DayCardPriceConfigServiceImpl.java @@ -0,0 +1,96 @@ +package io.as.modules.daycard.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.dao.DayCardPriceConfigDao; +import io.as.modules.daycard.entity.DayCardPriceConfigEntity; +import io.as.modules.daycard.service.DayCardPriceConfigService; +import org.apache.commons.lang.StringUtils; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.Map; + +/** + * 天卡定价策略 Service 实现 + */ +@Service("dayCardPriceConfigService") +public class DayCardPriceConfigServiceImpl extends ServiceImpl implements DayCardPriceConfigService { + + @Override + public PageUtils queryPage(Map params) { + IPage page = new Query().getPage(params, "created_at", false); + + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + + String partnerId = (String) params.get("partnerId"); + if (StringUtils.isNotBlank(partnerId)) { + wrapper.eq("partner_id", partnerId); + } + String status = (String) params.get("status"); + if (StringUtils.isNotBlank(status)) { + wrapper.eq("status", status); + } + String channelOrgId = (String) params.get("channelOrgId"); + if (StringUtils.isNotBlank(channelOrgId)) { + wrapper.eq("channel_org_id", channelOrgId); + } + String storeOrgId = (String) params.get("storeOrgId"); + if (StringUtils.isNotBlank(storeOrgId)) { + wrapper.eq("store_org_id", storeOrgId); + } + + IPage result = baseMapper.selectPage(page, wrapper); + return new PageUtils(result); + } + + @Override + public DayCardPriceConfigEntity getEffectivePrice(Long channelOrgId, Long storeOrgId) { + // 1. 门店配置价(最优) + if (storeOrgId != null) { + QueryWrapper storeWrapper = new QueryWrapper<>(); + storeWrapper.eq("store_org_id", storeOrgId) + .eq("status", "ACTIVE") + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .last("LIMIT 1"); + DayCardPriceConfigEntity storePrice = baseMapper.selectOne(storeWrapper); + if (storePrice != null) { + return storePrice; + } + } + + // 2. 渠道配置价(次优) + if (channelOrgId != null) { + QueryWrapper channelWrapper = new QueryWrapper<>(); + channelWrapper.isNull("store_org_id") + .eq("channel_org_id", channelOrgId) + .eq("status", "ACTIVE") + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .last("LIMIT 1"); + DayCardPriceConfigEntity channelPrice = baseMapper.selectOne(channelWrapper); + if (channelPrice != null) { + return channelPrice; + } + } + + // 3. 平台默认价兜底(store_org_id、channel_org_id 均为空) + QueryWrapper defaultWrapper = new QueryWrapper<>(); + defaultWrapper.isNull("store_org_id") + .isNull("channel_org_id") + .eq("status", "ACTIVE") + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .last("LIMIT 1"); + return baseMapper.selectOne(defaultWrapper); + } + + @Override + public BigDecimal getPrice(Long channelOrgId, Long storeOrgId) { + DayCardPriceConfigEntity config = getEffectivePrice(channelOrgId, storeOrgId); + return config != null ? config.getPrice() : null; + } +} diff --git a/src/main/java/io/as/modules/daycard/service/impl/DayCardProfitReceiverServiceImpl.java b/src/main/java/io/as/modules/daycard/service/impl/DayCardProfitReceiverServiceImpl.java new file mode 100644 index 0000000..ab27464 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/impl/DayCardProfitReceiverServiceImpl.java @@ -0,0 +1,107 @@ +package io.as.modules.daycard.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.dao.DayCardProfitReceiverDao; +import io.as.modules.daycard.entity.DayCardProfitReceiverEntity; +import io.as.modules.daycard.service.DayCardProfitReceiverService; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 天卡分账接收方 Service 实现 + */ +@Service("dayCardProfitReceiverService") +public class DayCardProfitReceiverServiceImpl extends ServiceImpl implements DayCardProfitReceiverService { + + private static final Logger logger = LoggerFactory.getLogger(DayCardProfitReceiverServiceImpl.class); + + @Override + public PageUtils queryPage(Map params) { + IPage page = new Query().getPage(params, "created_at", false); + + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + + String partnerId = (String) params.get("partnerId"); + if (StringUtils.isNotBlank(partnerId)) { + wrapper.eq("partner_id", partnerId); + } + String receiverType = (String) params.get("receiverType"); + if (StringUtils.isNotBlank(receiverType)) { + wrapper.eq("receiver_type", receiverType); + } + String wxBindStatus = (String) params.get("wxBindStatus"); + if (StringUtils.isNotBlank(wxBindStatus)) { + wrapper.eq("wx_bind_status", wxBindStatus); + } + + IPage result = baseMapper.selectPage(page, wrapper); + return new PageUtils(result); + } + + @Override + public DayCardProfitReceiverEntity getById(Long id) { + return baseMapper.selectById(id); + } + + @Override + public void saveReceiver(DayCardProfitReceiverEntity receiver) { + Date now = new Date(); + receiver.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL); + if (receiver.getWxBindStatus() == null) { + receiver.setWxBindStatus(DayCardConstants.WX_BIND_STATUS_UNBOUND); + } + receiver.setCreatedAt(now); + receiver.setUpdatedAt(now); + baseMapper.insert(receiver); + } + + @Override + public void updateReceiver(DayCardProfitReceiverEntity receiver) { + receiver.setUpdatedAt(new Date()); + baseMapper.updateById(receiver); + } + + @Override + public void delete(Long[] ids) { + if (ids == null || ids.length == 0) { + return; + } + List list = new ArrayList<>(); + Date now = new Date(); + for (Long id : ids) { + DayCardProfitReceiverEntity entity = new DayCardProfitReceiverEntity(); + entity.setId(id); + entity.setDeleteFlag(DayCardConstants.DELETE_FLAG_DEL); + entity.setUpdatedAt(now); + list.add(entity); + } + this.updateBatchById(list); + } + + @Override + public void syncToWechat(Long id) { + DayCardProfitReceiverEntity receiver = baseMapper.selectById(id); + if (receiver == null) { + return; + } + // 占位方法:记录日志,设置绑定状态 + logger.info("同步分账接收方到微信: id={}, receiverAccount={}", id, receiver.getReceiverAccount()); + // TODO: 实际微信API待对接 + receiver.setWxBindStatus(DayCardConstants.WX_BIND_STATUS_BINDED); + receiver.setUpdatedAt(new Date()); + baseMapper.updateById(receiver); + } +} diff --git a/src/main/java/io/as/modules/daycard/service/impl/DayCardProfitRuleServiceImpl.java b/src/main/java/io/as/modules/daycard/service/impl/DayCardProfitRuleServiceImpl.java new file mode 100644 index 0000000..d601fbe --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/impl/DayCardProfitRuleServiceImpl.java @@ -0,0 +1,156 @@ +package io.as.modules.daycard.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.dao.DayCardProfitRuleDao; +import io.as.modules.daycard.entity.DayCardProfitRuleEntity; +import io.as.modules.daycard.service.DayCardProfitRuleService; +import org.apache.commons.lang.StringUtils; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 天卡分润规则 Service 实现 + */ +@Service("dayCardProfitRuleService") +public class DayCardProfitRuleServiceImpl extends ServiceImpl implements DayCardProfitRuleService { + + @Override + public PageUtils queryPage(Map params) { + IPage page = new Query().getPage(params, "created_at", false); + + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + + String partnerId = (String) params.get("partnerId"); + if (StringUtils.isNotBlank(partnerId)) { + wrapper.eq("partner_id", partnerId); + } + String channelOrgId = (String) params.get("channelOrgId"); + if (StringUtils.isNotBlank(channelOrgId)) { + wrapper.eq("channel_org_id", channelOrgId); + } + String storeOrgId = (String) params.get("storeOrgId"); + if (StringUtils.isNotBlank(storeOrgId)) { + wrapper.eq("store_org_id", storeOrgId); + } + String status = (String) params.get("status"); + if (StringUtils.isNotBlank(status)) { + wrapper.eq("status", status); + } + + IPage result = baseMapper.selectPage(page, wrapper); + return new PageUtils(result); + } + + @Override + public DayCardProfitRuleEntity getById(Long id) { + return baseMapper.selectById(id); + } + + @Override + public void saveRule(DayCardProfitRuleEntity rule) { + validateShareRatio(rule); + Date now = new Date(); + rule.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL); + rule.setCreatedAt(now); + rule.setUpdatedAt(now); + baseMapper.insert(rule); + } + + @Override + public void updateRule(DayCardProfitRuleEntity rule) { + validateShareRatio(rule); + rule.setUpdatedAt(new Date()); + baseMapper.updateById(rule); + } + + @Override + public void delete(Long[] ids) { + if (ids == null || ids.length == 0) { + return; + } + List list = new ArrayList<>(); + Date now = new Date(); + for (Long id : ids) { + DayCardProfitRuleEntity entity = new DayCardProfitRuleEntity(); + entity.setId(id); + entity.setDeleteFlag(DayCardConstants.DELETE_FLAG_DEL); + entity.setUpdatedAt(now); + list.add(entity); + } + this.updateBatchById(list); + } + + @Override + public List matchRules(Long partnerId, Long channelOrgId, Long storeOrgId) { + // 1. 优先查门店级匹配的规则 + if (storeOrgId != null && channelOrgId != null) { + QueryWrapper storeWrapper = new QueryWrapper<>(); + storeWrapper.eq("partner_id", partnerId) + .eq("channel_org_id", channelOrgId) + .eq("store_org_id", storeOrgId) + .eq("status", DayCardConstants.PROFIT_RULE_STATUS_ACTIVE) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .orderByDesc("priority"); + List storeRules = baseMapper.selectList(storeWrapper); + if (!storeRules.isEmpty()) { + return storeRules; + } + } + + // 2. 无则查渠道级匹配的规则(store_org_id 为 null) + if (channelOrgId != null) { + QueryWrapper channelWrapper = new QueryWrapper<>(); + channelWrapper.eq("partner_id", partnerId) + .eq("channel_org_id", channelOrgId) + .isNull("store_org_id") + .eq("status", DayCardConstants.PROFIT_RULE_STATUS_ACTIVE) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .orderByDesc("priority"); + List channelRules = baseMapper.selectList(channelWrapper); + if (!channelRules.isEmpty()) { + return channelRules; + } + } + + // 3. 无则查合作商级匹配的规则(channel_org_id 和 store_org_id 都为 null) + QueryWrapper partnerWrapper = new QueryWrapper<>(); + partnerWrapper.eq("partner_id", partnerId) + .isNull("channel_org_id") + .isNull("store_org_id") + .eq("status", DayCardConstants.PROFIT_RULE_STATUS_ACTIVE) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .orderByDesc("priority"); + return baseMapper.selectList(partnerWrapper); + } + + /** + * 校验分润比例不超过 30% + */ + private void validateShareRatio(DayCardProfitRuleEntity rule) { + if (rule.getShareRatio() == null) { + throw new ASException("分润比例不能为空"); + } + if (DayCardConstants.SHARE_TYPE_FIXED.equals(rule.getShareType())) { + // 固定金额类型,不校验比例 + return; + } + if (rule.getShareRatio().compareTo(DayCardConstants.SHARE_RATIO_LIMIT) > 0) { + throw new ASException("分润比例不能超过30%"); + } + if (rule.getShareRatio().compareTo(BigDecimal.ZERO) <= 0) { + throw new ASException("分润比例必须大于0"); + } + } +} diff --git a/src/main/java/io/as/modules/daycard/service/impl/DayCardRefundServiceImpl.java b/src/main/java/io/as/modules/daycard/service/impl/DayCardRefundServiceImpl.java new file mode 100644 index 0000000..60f12a7 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/impl/DayCardRefundServiceImpl.java @@ -0,0 +1,301 @@ +package io.as.modules.daycard.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.dao.DayCardOrderDao; +import io.as.modules.daycard.dao.DayCardPoolDao; +import io.as.modules.daycard.dao.DayCardRefundDao; +import io.as.modules.daycard.entity.DayCardOrderEntity; +import io.as.modules.daycard.entity.DayCardPoolEntity; +import io.as.modules.daycard.entity.DayCardRefundEntity; +import io.as.modules.daycard.entity.ProfitSharingDetailEntity; +import io.as.modules.daycard.entity.ProfitSharingOrderEntity; +import io.as.modules.daycard.service.DayCardRefundService; +import io.as.modules.payment.service.WechatPayResult; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * 天卡退款 Service 实现 + */ +@Service("dayCardRefundService") +public class DayCardRefundServiceImpl extends ServiceImpl implements DayCardRefundService { + + private static final Logger logger = LoggerFactory.getLogger(DayCardRefundServiceImpl.class); + + @Autowired + private DayCardOrderDao dayCardOrderDao; + @Autowired + private DayCardPoolDao dayCardPoolDao; + + @Autowired + private io.as.modules.daycard.dao.ProfitSharingOrderDao profitSharingOrderDao; + @Autowired + private io.as.modules.daycard.dao.ProfitSharingDetailDao profitSharingDetailDao; + @Autowired + @org.springframework.context.annotation.Lazy + private io.as.modules.payment.service.WechatPayService wechatPayService; + + @Override + public PageUtils queryPage(Map params) { + IPage page = new Query().getPage(params, "created_at", false); + + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + + String refundNo = (String) params.get("refundNo"); + if (StringUtils.isNotBlank(refundNo)) { + wrapper.eq("refund_no", refundNo); + } + String orderNo = (String) params.get("orderNo"); + if (StringUtils.isNotBlank(orderNo)) { + wrapper.eq("order_no", orderNo); + } + String status = (String) params.get("status"); + if (StringUtils.isNotBlank(status)) { + wrapper.eq("status", status); + } + + IPage result = baseMapper.selectPage(page, wrapper); + return new PageUtils(result); + } + + @Override + public DayCardRefundEntity getByRefundNo(String refundNo) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("refund_no", refundNo) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + return baseMapper.selectOne(wrapper); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public DayCardRefundEntity refund(String orderNo, String reason) { + if (StringUtils.isBlank(orderNo)) { + throw new ASException("订单号不能为空"); + } + + Date now = new Date(); + + // 1. 查询订单,校验 status=PAID + QueryWrapper orderWrapper = new QueryWrapper<>(); + orderWrapper.eq("order_no", orderNo) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + DayCardOrderEntity order = dayCardOrderDao.selectOne(orderWrapper); + if (order == null) { + throw new ASException("订单不存在: " + orderNo); + } + if (!DayCardConstants.ORDER_STATUS_PAID.equals(order.getStatus())) { + throw new ASException("订单状态不允许退款,当前状态: " + order.getStatus()); + } + + // 2. 退款金额:优先取 payAmount,为空则用 amount + BigDecimal refundAmount = order.getPayAmount() != null ? order.getPayAmount() : order.getAmount(); + if (refundAmount == null || refundAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw new ASException("退款金额无效"); + } + + // 3. 生成退款单号(REFUND + yyyyMMddHHmmss + 6位随机) + String refundNo = generateRefundNo(); + + // 4. 创建退款记录 status=PENDING + DayCardRefundEntity refund = new DayCardRefundEntity(); + refund.setRefundNo(refundNo); + refund.setOrderNo(orderNo); + refund.setRefundAmount(refundAmount); + refund.setReason(reason); + refund.setStatus(DayCardConstants.REFUND_STATUS_PENDING); + refund.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL); + refund.setCreatedAt(now); + refund.setUpdatedAt(now); + baseMapper.insert(refund); + + try { + // 5. 更新订单状态为 REFUNDING + UpdateWrapper orderUpdate = new UpdateWrapper<>(); + orderUpdate.eq("id", order.getId()) + .eq("status", DayCardConstants.ORDER_STATUS_PAID) + .set("status", DayCardConstants.ORDER_STATUS_REFUNDING) + .set("updated_at", now); + int updated = dayCardOrderDao.update(null, orderUpdate); + if (updated == 0) { + throw new ASException("订单状态已变更,退款失败"); + } + + // 6. 释放卡:USED -> UNUSED,清除 device_id, order_no, expire_at, activate_status + UpdateWrapper poolUpdate = new UpdateWrapper<>(); + poolUpdate.eq("order_no", orderNo) + .eq("status", DayCardConstants.POOL_STATUS_USED) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .set("status", DayCardConstants.POOL_STATUS_UNUSED) + .set("order_no", null) + .set("device_id", null) + .set("expire_at", null) + .set("activate_status", null) + .set("updated_at", now); + dayCardPoolDao.update(null, poolUpdate); + logger.info("退款释放卡成功: orderNo={}", orderNo); + + // 7. 尝试回退分账(设置关联分账单状态为 CLOSED) + rollbackSettleRecords(orderNo, now); + + // 8. 调用微信退款(Mock支付则跳过) + String payNo = order.getPayNo(); + if (isWechatPayment(payNo)) { + BigDecimal totalAmount = order.getAmount(); + WechatPayResult refundResult = wechatPayService.refund(orderNo, refundNo, refundAmount, totalAmount, reason); + if (!refundResult.isSuccess()) { + throw new ASException("微信退款失败: " + refundResult.getErrorMsg()); + } + logger.info("微信退款成功: orderNo={}, refundNo={}", orderNo, refundNo); + } else { + logger.info("Mock支付退款,无需调用微信接口: orderNo={}, refundNo={}", orderNo, refundNo); + } + + // 9. 更新退款记录 status=SUCCESS + refund.setStatus(DayCardConstants.REFUND_STATUS_SUCCESS); + refund.setSettledAt(new Date()); + refund.setUpdatedAt(new Date()); + baseMapper.updateById(refund); + + logger.info("退款成功: orderNo={}, refundNo={}, refundAmount={}", orderNo, refundNo, refundAmount); + return refund; + + } catch (Exception e) { + logger.error("退款处理失败: orderNo={}, refundNo={}", orderNo, refundNo, e); + // 更新退款记录为 FAILED + String errorMsg = e.getMessage(); + if (errorMsg != null && errorMsg.length() > 450) { + errorMsg = errorMsg.substring(0, 450); + } + refund.setStatus(DayCardConstants.REFUND_STATUS_FAILED); + refund.setErrorMessage(errorMsg); + refund.setUpdatedAt(new Date()); + baseMapper.updateById(refund); + throw e; + } + } + + @Override + public void updateStatus(Long id, String status, String errorMessage) { + UpdateWrapper update = new UpdateWrapper<>(); + update.eq("id", id) + .set("status", status) + .set("error_message", errorMessage) + .set("updated_at", new Date()); + if (DayCardConstants.REFUND_STATUS_SUCCESS.equals(status) + || DayCardConstants.REFUND_STATUS_CLOSED.equals(status)) { + update.set("settled_at", new Date()); + } + baseMapper.update(null, update); + } + + /** + * 回退分账:查询订单关联的分账单,逐方回退已分账金额 + */ + private void rollbackSettleRecords(String orderNo, Date now) { + try { + // 查询关联的分账单 + QueryWrapper orderWrapper = new QueryWrapper<>(); + orderWrapper.eq("pay_order_no", orderNo) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + ProfitSharingOrderEntity sharingOrder = profitSharingOrderDao.selectOne(orderWrapper); + if (sharingOrder == null) { + logger.info("无关联分账单,跳过分账回退: orderNo={}", orderNo); + return; + } + + // 查询分账明细 + QueryWrapper detailWrapper = new QueryWrapper<>(); + detailWrapper.eq("sharing_order_id", sharingOrder.getId()); + List details = profitSharingDetailDao.selectList(detailWrapper); + if (details == null || details.isEmpty()) { + logger.info("无分账明细,跳过分账回退: orderNo={}", orderNo); + return; + } + + // 逐方回退已成功的分账 + for (ProfitSharingDetailEntity detail : details) { + if (!DayCardConstants.SETTLE_STATUS_SUCCESS.equals(detail.getStatus())) { + continue; + } + if (detail.getReceiverAccount() == null || detail.getAmount() == null) { + continue; + } + // 调用微信分账回退 API + String outReturnNo = "RT" + System.currentTimeMillis() + ThreadLocalRandom.current().nextInt(1000, 9999); + WechatPayResult returnResult = wechatPayService.profitSharingReturn( + sharingOrder.getOutOrderNo(), + detail.getReceiverAccount(), + detail.getAmount(), + outReturnNo); + + // 更新明细状态 + UpdateWrapper detailUpdate = new UpdateWrapper<>(); + detailUpdate.eq("id", detail.getId()); + if (returnResult.isSuccess()) { + detailUpdate.set("status", DayCardConstants.REFUND_STATUS_CLOSED) + .set("updated_at", now); + } else { + detailUpdate.set("fail_reason", returnResult.getErrorMsg()) + .set("updated_at", now); + } + profitSharingDetailDao.update(null, detailUpdate); + } + + // 更新分账单状态为 CLOSED + UpdateWrapper orderUpdate = new UpdateWrapper<>(); + orderUpdate.eq("id", sharingOrder.getId()) + .set("status", DayCardConstants.REFUND_STATUS_CLOSED) + .set("updated_at", now); + profitSharingOrderDao.update(null, orderUpdate); + + logger.info("分账回退完成: orderNo={}", orderNo); + } catch (Exception e) { + logger.warn("分账回退失败,跳过: orderNo={}", orderNo, e); + } + } + + /** + * 判断是否为微信支付 + *

Mock 支付的 payNo 以 "MOCK_" 开头,其余视为微信支付

+ * + * @param payNo 支付单号 + * @return true=微信支付 + */ + private boolean isWechatPayment(String payNo) { + if (StringUtils.isBlank(payNo)) { + // 没有支付单号,默认视为微信支付 + return true; + } + return !payNo.startsWith(DayCardConstants.PAY_CHANNEL_MOCK + "_"); + } + + /** + * 生成退款单号:REFUND + yyyyMMddHHmmss + 6位随机数 + */ + private String generateRefundNo() { + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); + String timestamp = sdf.format(new Date()); + int random = ThreadLocalRandom.current().nextInt(100000, 1000000); + return "REFUND" + timestamp + random; + } +} diff --git a/src/main/java/io/as/modules/daycard/service/impl/DayCardSettleOrderServiceImpl.java b/src/main/java/io/as/modules/daycard/service/impl/DayCardSettleOrderServiceImpl.java new file mode 100644 index 0000000..71b993c --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/impl/DayCardSettleOrderServiceImpl.java @@ -0,0 +1,100 @@ +package io.as.modules.daycard.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.dao.DayCardSettleOrderDao; +import io.as.modules.daycard.entity.DayCardSettleOrderEntity; +import io.as.modules.daycard.service.DayCardSettleOrderService; +import org.apache.commons.lang.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 天卡分账单 Service 实现 + */ +@Service("dayCardSettleOrderService") +public class DayCardSettleOrderServiceImpl extends ServiceImpl implements DayCardSettleOrderService { + + @Override + public PageUtils queryPage(Map params) { + IPage page = new Query().getPage(params, "created_at", false); + + QueryWrapper wrapper = new QueryWrapper<>(); + + String orderNo = (String) params.get("orderNo"); + if (StringUtils.isNotBlank(orderNo)) { + wrapper.eq("order_no", orderNo); + } + String status = (String) params.get("status"); + if (StringUtils.isNotBlank(status)) { + wrapper.eq("status", status); + } + + IPage result = baseMapper.selectPage(page, wrapper); + return new PageUtils(result); + } + + @Override + public DayCardSettleOrderEntity getBySettleNo(String settleNo) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("settle_no", settleNo); + return baseMapper.selectOne(wrapper); + } + + @Override + public void saveSettleOrder(DayCardSettleOrderEntity entity) { + Date now = new Date(); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + baseMapper.insert(entity); + } + + @Override + public void updateStatus(Long id, String status, String errorMessage) { + UpdateWrapper update = new UpdateWrapper<>(); + update.eq("id", id) + .set("status", status) + .set("error_message", errorMessage) + .set("updated_at", new Date()); + if (DayCardConstants.SETTLE_STATUS_SUCCESS.equals(status)) { + update.set("settled_at", new Date()); + } + baseMapper.update(null, update); + } + + @Override + public List getByOrderNo(String orderNo) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("order_no", orderNo) + .orderByDesc("created_at"); + return baseMapper.selectList(wrapper); + } + + @Override + public int retryFailed() { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("status", DayCardConstants.SETTLE_STATUS_FAILED) + .last("LIMIT 200"); + List failedList = baseMapper.selectList(wrapper); + + int retried = 0; + for (DayCardSettleOrderEntity settleOrder : failedList) { + try { + // 重置状态为 PENDING,等待重新执行 + updateStatus(settleOrder.getId(), DayCardConstants.SETTLE_STATUS_PENDING, null); + retried++; + } catch (Exception e) { + // 忽略单条重置失败 + } + } + return retried; + } +} diff --git a/src/main/java/io/as/modules/daycard/service/impl/DayCardSettleRecordServiceImpl.java b/src/main/java/io/as/modules/daycard/service/impl/DayCardSettleRecordServiceImpl.java new file mode 100644 index 0000000..c0d9754 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/impl/DayCardSettleRecordServiceImpl.java @@ -0,0 +1,54 @@ +package io.as.modules.daycard.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.daycard.dao.DayCardSettleRecordDao; +import io.as.modules.daycard.entity.DayCardSettleRecordEntity; +import io.as.modules.daycard.service.DayCardSettleRecordService; +import org.apache.commons.lang.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.Map; + +/** + * 天卡分账记录 Service 实现 + */ +@Service("dayCardSettleRecordService") +public class DayCardSettleRecordServiceImpl extends ServiceImpl implements DayCardSettleRecordService { + + @Override + public PageUtils queryPage(Map params) { + IPage page = new Query().getPage(params, "created_at", false); + + QueryWrapper wrapper = new QueryWrapper<>(); + + String orderNo = (String) params.get("orderNo"); + if (StringUtils.isNotBlank(orderNo)) { + wrapper.eq("order_no", orderNo); + } + String status = (String) params.get("status"); + if (StringUtils.isNotBlank(status)) { + wrapper.eq("status", status); + } + + IPage result = baseMapper.selectPage(page, wrapper); + return new PageUtils(result); + } + + @Override + public DayCardSettleRecordEntity getById(Long id) { + return baseMapper.selectById(id); + } + + @Override + public void saveRecord(DayCardSettleRecordEntity record) { + Date now = new Date(); + record.setCreatedAt(now); + record.setUpdatedAt(now); + baseMapper.insert(record); + } +} diff --git a/src/main/java/io/as/modules/daycard/service/impl/DayCardSettleRuleServiceImpl.java b/src/main/java/io/as/modules/daycard/service/impl/DayCardSettleRuleServiceImpl.java new file mode 100644 index 0000000..5de6568 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/impl/DayCardSettleRuleServiceImpl.java @@ -0,0 +1,113 @@ +package io.as.modules.daycard.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.dao.DayCardSettleRuleDao; +import io.as.modules.daycard.entity.DayCardSettleRuleEntity; +import io.as.modules.daycard.service.DayCardSettleRuleService; +import org.apache.commons.lang.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 天卡分账规则 Service 实现 + */ +@Service("dayCardSettleRuleService") +public class DayCardSettleRuleServiceImpl extends ServiceImpl implements DayCardSettleRuleService { + + @Override + public PageUtils queryPage(Map params) { + IPage page = new Query().getPage(params, "created_at", false); + + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + + String partnerId = (String) params.get("partnerId"); + if (StringUtils.isNotBlank(partnerId)) { + wrapper.eq("partner_id", partnerId); + } + String channelOrgId = (String) params.get("channelOrgId"); + if (StringUtils.isNotBlank(channelOrgId)) { + wrapper.eq("channel_org_id", channelOrgId); + } + String status = (String) params.get("status"); + if (StringUtils.isNotBlank(status)) { + wrapper.eq("status", status); + } + + IPage result = baseMapper.selectPage(page, wrapper); + return new PageUtils(result); + } + + @Override + public DayCardSettleRuleEntity getById(Long id) { + return baseMapper.selectById(id); + } + + @Override + public void saveRule(DayCardSettleRuleEntity rule) { + Date now = new Date(); + rule.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL); + rule.setCreatedAt(now); + rule.setUpdatedAt(now); + baseMapper.insert(rule); + } + + @Override + public void update(DayCardSettleRuleEntity rule) { + rule.setUpdatedAt(new Date()); + baseMapper.updateById(rule); + } + + @Override + public void delete(Long[] ids) { + if (ids == null || ids.length == 0) { + return; + } + List list = new ArrayList<>(); + Date now = new Date(); + for (Long id : ids) { + DayCardSettleRuleEntity entity = new DayCardSettleRuleEntity(); + entity.setId(id); + entity.setDeleteFlag(DayCardConstants.DELETE_FLAG_DEL); + entity.setUpdatedAt(now); + list.add(entity); + } + this.updateBatchById(list); + } + + @Override + public List getRulesByOrder(Long partnerId, Long channelOrgId, Long storeOrgId) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("partner_id", partnerId) + .eq("status", "ACTIVE") + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + + // 门店级规则(store_org_id 不为空且匹配) + if (storeOrgId != null) { + QueryWrapper storeWrapper = new QueryWrapper<>(); + storeWrapper.eq("partner_id", partnerId) + .eq("channel_org_id", channelOrgId) + .eq("store_org_id", storeOrgId) + .eq("status", "ACTIVE") + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + List storeRules = baseMapper.selectList(storeWrapper); + if (!storeRules.isEmpty()) { + return storeRules; + } + } + + // 渠道级规则(store_org_id 为空) + wrapper.eq("channel_org_id", channelOrgId) + .isNull("store_org_id"); + return baseMapper.selectList(wrapper); + } +} diff --git a/src/main/java/io/as/modules/daycard/service/impl/OrgSettlementAccountServiceImpl.java b/src/main/java/io/as/modules/daycard/service/impl/OrgSettlementAccountServiceImpl.java new file mode 100644 index 0000000..e886486 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/impl/OrgSettlementAccountServiceImpl.java @@ -0,0 +1,188 @@ +package io.as.modules.daycard.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.dao.OrgSettlementAccountDao; +import io.as.modules.daycard.entity.OrgSettlementAccountEntity; +import io.as.modules.daycard.service.OrgSettlementAccountService; +import io.as.modules.payment.service.WechatPayResult; +import io.as.modules.payment.service.WechatPayService; +import io.as.modules.sys.dao.SysOrgDao; +import io.as.modules.sys.entity.SysOrgEntity; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 组织分账接收方 Service 实现 + */ +@Service("orgSettlementAccountService") +public class OrgSettlementAccountServiceImpl extends ServiceImpl implements OrgSettlementAccountService { + + private static final Logger logger = LoggerFactory.getLogger(OrgSettlementAccountServiceImpl.class); + + @Autowired + private SysOrgDao sysOrgDao; + + @Autowired + private io.as.modules.payment.service.WechatPayService wechatPayService; + + @Override + public PageUtils queryPage(Map params) { + IPage page = new Query().getPage(params, "created_at", false); + + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + + String orgId = (String) params.get("orgId"); + if (StringUtils.isNotBlank(orgId)) { + wrapper.eq("org_id", orgId); + } + String bindStatus = (String) params.get("bindStatus"); + if (StringUtils.isNotBlank(bindStatus)) { + wrapper.eq("bind_status", bindStatus); + } + String receiverType = (String) params.get("receiverType"); + if (StringUtils.isNotBlank(receiverType)) { + wrapper.eq("receiver_type", receiverType); + } + + IPage result = baseMapper.selectPage(page, wrapper); + + // 填充组织名称 + for (OrgSettlementAccountEntity entity : result.getRecords()) { + if (entity.getOrgId() != null) { + SysOrgEntity org = sysOrgDao.selectById(entity.getOrgId()); + if (org != null) { + entity.setOrgName(org.getName()); + } + } + } + + return new PageUtils(result); + } + + @Override + public OrgSettlementAccountEntity getById(Long id) { + OrgSettlementAccountEntity entity = baseMapper.selectById(id); + if (entity != null && entity.getOrgId() != null) { + SysOrgEntity org = sysOrgDao.selectById(entity.getOrgId()); + if (org != null) { + entity.setOrgName(org.getName()); + } + } + return entity; + } + + @Override + public void saveAccount(OrgSettlementAccountEntity account) { + Date now = new Date(); + account.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL); + if (account.getBindStatus() == null) { + account.setBindStatus(DayCardConstants.BIND_STATUS_PENDING); + } + if (account.getPayChannel() == null) { + account.setPayChannel(DayCardConstants.PAY_CHANNEL_WECHAT); + } + account.setCreatedAt(now); + account.setUpdatedAt(now); + + // 调用微信添加分账接收方 + WechatPayResult addResult = wechatPayService.addReceiver( + account.getReceiverType(), + account.getReceiverAccount(), + account.getReceiverName(), + "STORE"); + if (addResult.isSuccess()) { + account.setBindStatus(DayCardConstants.BIND_STATUS_ACTIVE); + account.setExternalBindId(addResult.getPayNo()); + } else { + account.setBindStatus(DayCardConstants.BIND_STATUS_FAILED); + logger.warn("微信添加分账接收方失败: account={}, error={}", account.getReceiverAccount(), addResult.getErrorMsg()); + } + + baseMapper.insert(account); + } + + @Override + public void delete(Long[] ids) { + if (ids == null || ids.length == 0) { + return; + } + + // 查询待删除的实体列表 + List list = new ArrayList<>(); + Date now = new Date(); + for (Long id : ids) { + OrgSettlementAccountEntity entity = new OrgSettlementAccountEntity(); + entity.setId(id); + entity.setDeleteFlag(DayCardConstants.DELETE_FLAG_DEL); + entity.setUpdatedAt(now); + list.add(entity); + } + + // 调用微信删除分账接收方 + for (OrgSettlementAccountEntity entity : list) { + // 先查询完整的实体信息 + OrgSettlementAccountEntity full = baseMapper.selectById(entity.getId()); + if (full != null && full.getReceiverAccount() != null) { + WechatPayResult delResult = wechatPayService.deleteReceiver( + full.getReceiverType(), full.getReceiverAccount()); + if (!delResult.isSuccess()) { + logger.warn("微信删除分账接收方失败: account={}, error={}", + full.getReceiverAccount(), delResult.getErrorMsg()); + } + } + } + + this.updateBatchById(list); + } + + @Override + public void syncToWechat(Long id) { + OrgSettlementAccountEntity account = baseMapper.selectById(id); + if (account == null) { + return; + } + logger.info("同步分账接收方到微信: id={}, orgId={}, receiverAccount={}", id, account.getOrgId(), account.getReceiverAccount()); + + WechatPayResult result = wechatPayService.addReceiver( + account.getReceiverType(), + account.getReceiverAccount(), + account.getReceiverName(), + "STORE"); + + UpdateWrapper update = new UpdateWrapper<>(); + update.eq("id", id) + .set("updated_at", new Date()); + if (result.isSuccess()) { + update.set("bind_status", DayCardConstants.BIND_STATUS_ACTIVE); + update.set("external_bind_id", result.getPayNo()); + } else { + update.set("bind_status", DayCardConstants.BIND_STATUS_FAILED); + } + baseMapper.update(null, update); + } + + @Override + public OrgSettlementAccountEntity getByOrgId(Long orgId) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("org_id", orgId) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .eq("bind_status", DayCardConstants.BIND_STATUS_ACTIVE) + .last("LIMIT 1"); + return baseMapper.selectOne(wrapper); + } +} diff --git a/src/main/java/io/as/modules/daycard/service/impl/ProfitCalcServiceImpl.java b/src/main/java/io/as/modules/daycard/service/impl/ProfitCalcServiceImpl.java new file mode 100644 index 0000000..6f42886 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/impl/ProfitCalcServiceImpl.java @@ -0,0 +1,126 @@ +package io.as.modules.daycard.service.impl; + +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.entity.DayCardOrderEntity; +import io.as.modules.daycard.entity.DayCardProfitRuleEntity; +import io.as.modules.daycard.entity.DayCardSettleOrderEntity; +import io.as.modules.daycard.service.DayCardProfitRuleService; +import io.as.modules.daycard.service.ProfitCalcService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * 分润计算 Service 实现 + */ +@Service("profitCalcService") +public class ProfitCalcServiceImpl implements ProfitCalcService { + + private static final Logger logger = LoggerFactory.getLogger(ProfitCalcServiceImpl.class); + + @Autowired + private DayCardProfitRuleService dayCardProfitRuleService; + + @Override + public Map calculate(Long partnerId, Long channelOrgId, Long storeOrgId, BigDecimal totalAmount) { + Map result = new HashMap<>(); + result.put("totalAmount", totalAmount); + + List rules = dayCardProfitRuleService.matchRules(partnerId, channelOrgId, storeOrgId); + + List> ruleList = new ArrayList<>(); + BigDecimal totalSettleAmount = BigDecimal.ZERO; + + for (DayCardProfitRuleEntity rule : rules) { + BigDecimal settleAmount = calcSettleAmount(rule, totalAmount); + + Map ruleMap = new HashMap<>(); + ruleMap.put("ruleId", rule.getId()); + ruleMap.put("receiverAccount", rule.getReceiverAccount()); + ruleMap.put("receiverName", rule.getReceiverName()); + ruleMap.put("shareRatio", rule.getShareRatio()); + ruleMap.put("shareType", rule.getShareType()); + ruleMap.put("settleAmount", settleAmount); + ruleList.add(ruleMap); + + totalSettleAmount = totalSettleAmount.add(settleAmount); + } + + result.put("rules", ruleList); + result.put("totalSettleAmount", totalSettleAmount); + return result; + } + + @Override + public List buildSettleOrders(String orderNo, DayCardOrderEntity order) { + List settleOrders = new ArrayList<>(); + + List rules = dayCardProfitRuleService.matchRules( + order.getPartnerId(), order.getChannelOrgId(), order.getStoreOrgId()); + + if (rules == null || rules.isEmpty()) { + logger.info("未匹配到分润规则,跳过分账: orderNo={}", orderNo); + return settleOrders; + } + + BigDecimal totalAmount = order.getPayAmount() != null ? order.getPayAmount() : order.getAmount(); + Date now = new Date(); + + for (DayCardProfitRuleEntity rule : rules) { + BigDecimal settleAmount = calcSettleAmount(rule, totalAmount); + + DayCardSettleOrderEntity settleOrder = new DayCardSettleOrderEntity(); + settleOrder.setSettleNo(generateSettleNo()); + settleOrder.setOrderNo(orderNo); + settleOrder.setPartnerId(order.getPartnerId()); + settleOrder.setRuleId(rule.getId()); + settleOrder.setReceiverAccount(rule.getReceiverAccount()); + settleOrder.setReceiverName(rule.getReceiverName()); + settleOrder.setShareRatio(rule.getShareRatio()); + settleOrder.setShareType(rule.getShareType()); + settleOrder.setSettleAmount(settleAmount); + settleOrder.setTotalAmount(totalAmount); + settleOrder.setStatus(DayCardConstants.SETTLE_STATUS_PENDING); + settleOrder.setCreatedAt(now); + settleOrder.setUpdatedAt(now); + + settleOrders.add(settleOrder); + } + + return settleOrders; + } + + /** + * 计算单条规则的分账金额 + *

RATIO: totalAmount * shareRatio

+ *

FIXED: shareRatio 直接作为金额

+ */ + private BigDecimal calcSettleAmount(DayCardProfitRuleEntity rule, BigDecimal totalAmount) { + if (DayCardConstants.SHARE_TYPE_FIXED.equals(rule.getShareType())) { + return rule.getShareRatio().setScale(2, RoundingMode.HALF_UP); + } + // 默认按比例 + return totalAmount.multiply(rule.getShareRatio()).setScale(2, RoundingMode.HALF_UP); + } + + /** + * 生成分账单号:ST + yyyyMMddHHmmss + 6位随机数 + */ + private String generateSettleNo() { + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); + String timestamp = sdf.format(new Date()); + int random = ThreadLocalRandom.current().nextInt(100000, 1000000); + return "ST" + timestamp + random; + } +} diff --git a/src/main/java/io/as/modules/daycard/service/impl/ProfitSharingExecuteServiceImpl.java b/src/main/java/io/as/modules/daycard/service/impl/ProfitSharingExecuteServiceImpl.java new file mode 100644 index 0000000..92163b1 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/impl/ProfitSharingExecuteServiceImpl.java @@ -0,0 +1,465 @@ +package io.as.modules.daycard.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.dao.DayCardOrderDao; +import io.as.modules.daycard.dao.OrgSettlementAccountDao; +import io.as.modules.daycard.dao.ProfitSharingDetailDao; +import io.as.modules.daycard.dao.ProfitSharingOrderDao; +import io.as.modules.daycard.entity.DayCardOrderEntity; +import io.as.modules.daycard.entity.OrgSettlementAccountEntity; +import io.as.modules.daycard.entity.ProfitSharingDetailEntity; +import io.as.modules.daycard.entity.ProfitSharingOrderEntity; +import io.as.modules.daycard.entity.ProfitSharingRuleEntity; +import io.as.modules.daycard.entity.ProfitSharingRuleItemEntity; +import io.as.modules.daycard.service.ProfitSharingExecuteService; +import io.as.modules.daycard.service.ProfitSharingRuleService; +import io.as.modules.payment.service.WechatPayResult; +import io.as.modules.payment.service.WechatPayService; +import io.as.modules.sys.dao.SysOrgDao; +import io.as.modules.sys.entity.SysOrgEntity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * 分账执行 Service 实现 + */ +@Service("profitSharingExecuteService") +public class ProfitSharingExecuteServiceImpl implements ProfitSharingExecuteService { + + private static final Logger logger = LoggerFactory.getLogger(ProfitSharingExecuteServiceImpl.class); + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private DayCardOrderDao dayCardOrderDao; + + @Autowired + private ProfitSharingOrderDao profitSharingOrderDao; + + @Autowired + private ProfitSharingDetailDao profitSharingDetailDao; + + @Autowired(required = false) + private ProfitSharingRuleService profitSharingRuleService; + + @Autowired + private OrgSettlementAccountDao orgSettlementAccountDao; + + @Autowired + private SysOrgDao sysOrgDao; + + @Autowired + private io.as.modules.payment.service.WechatPayService wechatPayService; + + @Override + @Transactional(rollbackFor = Exception.class) + public void executeSharing(String orderNo) { + // 1. 查询订单 + QueryWrapper orderWrapper = new QueryWrapper<>(); + orderWrapper.eq("order_no", orderNo) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + DayCardOrderEntity order = dayCardOrderDao.selectOne(orderWrapper); + if (order == null) { + logger.warn("分账执行失败:订单不存在, orderNo={}", orderNo); + return; + } + + // 2. 幂等检查:查 profit_sharing_order by pay_order_no,已存在则跳过 + QueryWrapper existWrapper = new QueryWrapper<>(); + existWrapper.eq("pay_order_no", orderNo); + Integer existCount = profitSharingOrderDao.selectCount(existWrapper); + if (existCount != null && existCount > 0) { + logger.info("分账单已存在,跳过: orderNo={}", orderNo); + return; + } + + // 3. 从订单获取 storeOrgId, agentOrgId, channelOrgId, orgPathSnapshot + Long storeOrgId = order.getStoreOrgId(); + Long channelOrgId = order.getChannelOrgId(); + Long agentOrgId = resolveAgentOrgId(storeOrgId); + Long platformOrgId = resolvePlatformOrgId(storeOrgId); + String orgPathSnapshot = buildOrgPathSnapshot(storeOrgId, agentOrgId, channelOrgId); + + // 4. 调用 profitSharingRuleService.matchRule() 匹配规则 + if (profitSharingRuleService == null) { + logger.warn("ProfitSharingRuleService 未注入,跳过分账: orderNo={}", orderNo); + return; + } + + ProfitSharingRuleEntity rule = profitSharingRuleService.matchRule(storeOrgId, agentOrgId, channelOrgId); + if (rule == null || rule.getItems() == null || rule.getItems().isEmpty()) { + logger.info("无匹配的分账规则,跳过分账: orderNo={}", orderNo); + return; + } + + // 5. 获取 rule_items,计算各方分账金额 + // 金额单位:分(int),从订单 amount(元)转换 + BigDecimal amountYuan = order.getAmount(); + if (amountYuan == null) { + logger.warn("订单金额为空,跳过分账: orderNo={}", orderNo); + return; + } + int totalAmount = amountYuan.multiply(new BigDecimal("100")).intValue(); + + List ruleItems = rule.getItems(); + List details = new ArrayList<>(); + + int nonPlatformSum = 0; + BigDecimal nonPlatformTotalRatio = BigDecimal.ZERO; + + for (ProfitSharingRuleItemEntity item : ruleItems) { + ProfitSharingDetailEntity detail = new ProfitSharingDetailEntity(); + detail.setOrgRole(item.getOrgRole()); + detail.setOrgId(resolveOrgIdByRole(item.getOrgRole(), platformOrgId, channelOrgId, agentOrgId, storeOrgId)); + detail.setStatus(DayCardConstants.SETTLE_STATUS_PENDING); + + if (DayCardConstants.SHARE_TYPE_FIXED.equals(item.getShareType())) { + // FIXED: shareValue 即为金额(分) + detail.setAmount(item.getShareValue().intValue()); + } else { + // RATIO: totalAmount * shareValue / 100 + int amt = BigDecimal.valueOf(totalAmount) + .multiply(item.getShareValue()) + .divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN) + .intValue(); + detail.setAmount(amt); + } + + detail.setDescription("分账-" + (item.getOrgRole() != null ? item.getOrgRole() : "UNKNOWN")); + + // 累计非平台方金额和比例 + if (!DayCardConstants.ORG_ROLE_PLATFORM.equals(item.getOrgRole())) { + nonPlatformSum += detail.getAmount(); + if (DayCardConstants.SHARE_TYPE_RATIO.equals(item.getShareType()) && item.getShareValue() != null) { + nonPlatformTotalRatio = nonPlatformTotalRatio.add(item.getShareValue()); + } + } + + details.add(detail); + } + + // 尾差归平台:PLATFORM 的金额 = totalAmount - 其他各方之和 + for (ProfitSharingDetailEntity detail : details) { + if (DayCardConstants.ORG_ROLE_PLATFORM.equals(detail.getOrgRole())) { + detail.setAmount(totalAmount - nonPlatformSum); + break; + } + } + + // 6. 二次校验非平台方总比例 ≤ 30% + if (nonPlatformTotalRatio.compareTo(BigDecimal.valueOf(DayCardConstants.PROFIT_SHARE_LIMIT)) > 0) { + logger.error("非平台方总比例超过30%限制: orderNo={}, ratioSum={}", orderNo, nonPlatformTotalRatio); + return; + } + + // 7. 创建 profit_sharing_order(存 rule_snapshot JSON) + String outOrderNo = generateOutOrderNo(); + String ruleSnapshot = serializeRuleSnapshot(rule); + + ProfitSharingOrderEntity sharingOrder = new ProfitSharingOrderEntity(); + sharingOrder.setPayOrderNo(orderNo); + sharingOrder.setTransactionId(order.getPayNo()); + sharingOrder.setOutOrderNo(outOrderNo); + sharingOrder.setStatus(DayCardConstants.SETTLE_STATUS_PENDING); + sharingOrder.setTotalAmount(totalAmount); + sharingOrder.setRuleId(rule.getId()); + sharingOrder.setRuleSnapshot(ruleSnapshot); + sharingOrder.setOrgPathSnapshot(orgPathSnapshot); + sharingOrder.setSharingChannel("WECHAT_NATIVE"); + sharingOrder.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL); + Date now = new Date(); + sharingOrder.setCreatedAt(now); + sharingOrder.setUpdatedAt(now); + profitSharingOrderDao.insert(sharingOrder); + + // 8. 批量创建 profit_sharing_detail + for (ProfitSharingDetailEntity detail : details) { + detail.setSharingOrderId(sharingOrder.getId()); + detail.setCreatedAt(now); + detail.setUpdatedAt(now); + profitSharingDetailDao.insert(detail); + } + + logger.info("分账单已创建: orderNo={}, outOrderNo={}, detailCount={}", orderNo, outOrderNo, details.size()); + + // 9. 查询各接收方的 org_settlement_account(bind_status=ACTIVE) + for (ProfitSharingDetailEntity detail : details) { + if (detail.getOrgId() != null) { + OrgSettlementAccountEntity account = orgSettlementAccountDao.selectOne( + new QueryWrapper() + .eq("org_id", detail.getOrgId()) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .eq("bind_status", DayCardConstants.BIND_STATUS_ACTIVE) + .last("LIMIT 1")); + if (account != null) { + detail.setReceiverAccount(account.getReceiverAccount()); + detail.setReceiverType(account.getReceiverType()); + } else { + logger.warn("未找到活跃的结算账户: orgId={}, orgRole={}", detail.getOrgId(), detail.getOrgRole()); + } + } + } + + // 10. 调用微信分账 API + // 更新分账单状态为 PROCESSING + UpdateWrapper orderUpdate = new UpdateWrapper<>(); + orderUpdate.eq("id", sharingOrder.getId()) + .set("status", DayCardConstants.SETTLE_STATUS_PROCESSING) + .set("updated_at", new Date()); + profitSharingOrderDao.update(null, orderUpdate); + + // 构建分账接收方列表 + List> receivers = new ArrayList<>(); + for (ProfitSharingDetailEntity detail : details) { + if (detail.getReceiverAccount() == null || DayCardConstants.ORG_ROLE_PLATFORM.equals(detail.getOrgRole())) { + continue; // 平台方通过完结分账获取留成,不需要分账接收方 + } + Map receiver = new HashMap<>(); + receiver.put("type", detail.getReceiverType() != null ? detail.getReceiverType() : "MERCHANT_ID"); + receiver.put("account", detail.getReceiverAccount()); + receiver.put("amount", detail.getAmount()); + receiver.put("description", detail.getDescription() != null ? detail.getDescription() : "分账"); + receivers.add(receiver); + } + + // 调用微信分账 API + String transactionId = order.getPayNo(); + WechatPayResult sharingResult = wechatPayService.createProfitSharing(transactionId, outOrderNo, receivers); + if (!sharingResult.isSuccess()) { + // 分账失败,更新状态 + UpdateWrapper failUpdate = new UpdateWrapper<>(); + failUpdate.eq("id", sharingOrder.getId()) + .set("status", DayCardConstants.SETTLE_STATUS_FAILED) + .set("updated_at", new Date()); + profitSharingOrderDao.update(null, failUpdate); + logger.error("微信分账失败: orderNo={}, outOrderNo={}, error={}", orderNo, outOrderNo, sharingResult.getErrorMsg()); + return; + } + logger.info("微信分账API调用成功: outOrderNo={}", outOrderNo); + + // 模拟/实际更新所有 detail 状态为 SUCCESS + for (ProfitSharingDetailEntity detail : details) { + UpdateWrapper detailUpdate = new UpdateWrapper<>(); + detailUpdate.eq("id", detail.getId()) + .set("status", DayCardConstants.SETTLE_STATUS_SUCCESS) + .set("success_time", new Date()) + .set("updated_at", new Date()); + profitSharingDetailDao.update(null, detailUpdate); + } + + // 11. 调用完结分账 API + boolean finishSuccess = wechatPayService.finishProfitSharing(outOrderNo, transactionId); + if (finishSuccess) { + UpdateWrapper finishUpdate = new UpdateWrapper<>(); + finishUpdate.eq("id", sharingOrder.getId()) + .set("status", DayCardConstants.SETTLE_STATUS_SUCCESS) + .set("updated_at", new Date()); + profitSharingOrderDao.update(null, finishUpdate); + logger.info("分账执行成功: orderNo={}, outOrderNo={}", orderNo, outOrderNo); + } else { + logger.warn("完结分账失败,分账单保持PROCESSING状态: outOrderNo={}", outOrderNo); + } + } + + @Override + public void retryFailed() { + // 1. 查 profit_sharing_order status=PENDING 或 FAILED 且 created_at < NOW() - 5分钟 + Date threshold = new Date(System.currentTimeMillis() - 5 * 60 * 1000L); + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.in("status", DayCardConstants.SETTLE_STATUS_PENDING, DayCardConstants.SETTLE_STATUS_FAILED) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .lt("created_at", threshold) + .last("LIMIT 200"); + List failedOrders = profitSharingOrderDao.selectList(wrapper); + + if (failedOrders.isEmpty()) { + return; + } + + logger.info("开始重试失败的分账单,共 {} 笔", failedOrders.size()); + + int retried = 0; + for (ProfitSharingOrderEntity sharingOrder : failedOrders) { + try { + retrySingle(sharingOrder); + retried++; + } catch (Exception e) { + logger.error("重试分账单失败: outOrderNo={}", sharingOrder.getOutOrderNo(), e); + } + } + + logger.info("重试分账单完成,成功 {} 笔", retried); + } + + /** + * 重试单笔分账单 + */ + private void retrySingle(ProfitSharingOrderEntity sharingOrder) { + // 查询明细 + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("sharing_order_id", sharingOrder.getId()); + List details = profitSharingDetailDao.selectList(wrapper); + + // 更新分账单状态为 PROCESSING + UpdateWrapper orderUpdate = new UpdateWrapper<>(); + orderUpdate.eq("id", sharingOrder.getId()) + .set("status", DayCardConstants.SETTLE_STATUS_PROCESSING) + .set("updated_at", new Date()); + profitSharingOrderDao.update(null, orderUpdate); + + // 调用微信查询分账状态 + WechatPayResult queryResult = wechatPayService.queryProfitSharing(sharingOrder.getOutOrderNo()); + if (queryResult.isSuccess()) { + logger.info("微信分账查询成功: outOrderNo={}", sharingOrder.getOutOrderNo()); + } else { + logger.warn("微信分账查询失败: outOrderNo={}, error={}", sharingOrder.getOutOrderNo(), queryResult.getErrorMsg()); + } + + // 占位:模拟调用微信分账 API 成功 + logger.info("重试分账(占位): outOrderNo={}", sharingOrder.getOutOrderNo()); + + // 更新所有 detail 状态为 SUCCESS + for (ProfitSharingDetailEntity detail : details) { + if (!DayCardConstants.SETTLE_STATUS_SUCCESS.equals(detail.getStatus())) { + UpdateWrapper detailUpdate = new UpdateWrapper<>(); + detailUpdate.eq("id", detail.getId()) + .set("status", DayCardConstants.SETTLE_STATUS_SUCCESS) + .set("success_time", new Date()) + .set("updated_at", new Date()); + profitSharingDetailDao.update(null, detailUpdate); + } + } + + // 更新分账单状态为 SUCCESS + UpdateWrapper finishUpdate = new UpdateWrapper<>(); + finishUpdate.eq("id", sharingOrder.getId()) + .set("status", DayCardConstants.SETTLE_STATUS_SUCCESS) + .set("updated_at", new Date()); + profitSharingOrderDao.update(null, finishUpdate); + + logger.info("重试分账成功: outOrderNo={}", sharingOrder.getOutOrderNo()); + } + + /** + * 解析代理组织ID(门店的父组织) + */ + private Long resolveAgentOrgId(Long storeOrgId) { + if (storeOrgId == null) { + return null; + } + SysOrgEntity storeOrg = sysOrgDao.selectById(storeOrgId); + if (storeOrg == null || storeOrg.getParentId() == null) { + return null; + } + return storeOrg.getParentId(); + } + + /** + * 解析平台组织ID(mpath 第一段) + */ + private Long resolvePlatformOrgId(Long storeOrgId) { + if (storeOrgId == null) { + return null; + } + SysOrgEntity storeOrg = sysOrgDao.selectById(storeOrgId); + if (storeOrg == null || storeOrg.getMpath() == null) { + return null; + } + String[] segments = storeOrg.getMpath().split("\\."); + if (segments.length >= 1) { + return Long.parseLong(segments[0]); + } + return null; + } + + /** + * 根据组织角色解析对应的组织ID + */ + private Long resolveOrgIdByRole(String orgRole, Long platformOrgId, Long channelOrgId, Long agentOrgId, Long storeOrgId) { + if (orgRole == null) { + return null; + } + switch (orgRole) { + case DayCardConstants.ORG_ROLE_PLATFORM: + return platformOrgId; + case DayCardConstants.ORG_ROLE_CHANNEL: + return channelOrgId; + case DayCardConstants.ORG_ROLE_AGENT: + return agentOrgId; + case DayCardConstants.ORG_ROLE_STORE: + return storeOrgId; + default: + return null; + } + } + + /** + * 构建组织链路快照 + */ + private String buildOrgPathSnapshot(Long storeOrgId, Long agentOrgId, Long channelOrgId) { + StringBuilder sb = new StringBuilder(); + if (channelOrgId != null) { + SysOrgEntity channelOrg = sysOrgDao.selectById(channelOrgId); + if (channelOrg != null) { + sb.append(channelOrg.getName()); + } + } + if (agentOrgId != null) { + SysOrgEntity agentOrg = sysOrgDao.selectById(agentOrgId); + if (agentOrg != null) { + if (sb.length() > 0) { + sb.append(" > "); + } + sb.append(agentOrg.getName()); + } + } + if (storeOrgId != null) { + SysOrgEntity storeOrg = sysOrgDao.selectById(storeOrgId); + if (storeOrg != null) { + if (sb.length() > 0) { + sb.append(" > "); + } + sb.append(storeOrg.getName()); + } + } + return sb.toString(); + } + + /** + * 序列化规则快照为 JSON + */ + private String serializeRuleSnapshot(ProfitSharingRuleEntity rule) { + try { + return objectMapper.writeValueAsString(rule); + } catch (Exception e) { + logger.warn("序列化规则快照失败: ruleId={}", rule.getId(), e); + return "{}"; + } + } + + /** + * 生成分账内部单号:PS + yyyyMMddHHmmss + 6位随机数 + */ + private String generateOutOrderNo() { + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); + String timestamp = sdf.format(new Date()); + int random = ThreadLocalRandom.current().nextInt(100000, 1000000); + return "PS" + timestamp + random; + } +} diff --git a/src/main/java/io/as/modules/daycard/service/impl/ProfitSharingRuleServiceImpl.java b/src/main/java/io/as/modules/daycard/service/impl/ProfitSharingRuleServiceImpl.java new file mode 100644 index 0000000..4426281 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/impl/ProfitSharingRuleServiceImpl.java @@ -0,0 +1,217 @@ +package io.as.modules.daycard.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.dao.ProfitSharingRuleDao; +import io.as.modules.daycard.dao.ProfitSharingRuleItemDao; +import io.as.modules.daycard.entity.ProfitSharingRuleEntity; +import io.as.modules.daycard.entity.ProfitSharingRuleItemEntity; +import io.as.modules.daycard.service.ProfitSharingRuleService; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 分润规则 Service 实现 + */ +@Service("profitSharingRuleService") +public class ProfitSharingRuleServiceImpl extends ServiceImpl implements ProfitSharingRuleService { + + @Autowired + private ProfitSharingRuleItemDao profitSharingRuleItemDao; + + @Override + public PageUtils queryPage(Map params) { + IPage page = new Query().getPage(params, "created_at", false); + + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL); + + String scopeType = (String) params.get("scopeType"); + if (StringUtils.isNotBlank(scopeType)) { + wrapper.eq("scope_type", scopeType); + } + String status = (String) params.get("status"); + if (StringUtils.isNotBlank(status)) { + wrapper.eq("status", status); + } + + IPage result = baseMapper.selectPage(page, wrapper); + return new PageUtils(result); + } + + @Override + public ProfitSharingRuleEntity getInfo(Long id) { + ProfitSharingRuleEntity rule = baseMapper.selectById(id); + if (rule == null) { + return null; + } + // 查询子表 items + QueryWrapper itemWrapper = new QueryWrapper<>(); + itemWrapper.eq("rule_id", id).orderByAsc("sort_order"); + List items = profitSharingRuleItemDao.selectList(itemWrapper); + rule.setItems(items); + return rule; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void saveRule(ProfitSharingRuleEntity rule) { + Date now = new Date(); + rule.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL); + rule.setCreatedAt(now); + rule.setUpdatedAt(now); + // 1. 插入主表获取 id + baseMapper.insert(rule); + + // 2. 批量插入 items + List items = rule.getItems(); + if (items != null && !items.isEmpty()) { + for (ProfitSharingRuleItemEntity item : items) { + item.setRuleId(rule.getId()); + item.setCreatedAt(now); + item.setUpdatedAt(now); + profitSharingRuleItemDao.insert(item); + } + } + + // 3. 30%校验 + validateShareRatio(items); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateRule(ProfitSharingRuleEntity rule) { + Date now = new Date(); + rule.setUpdatedAt(now); + // 1. 更新主表 + baseMapper.updateById(rule); + + // 2. 删除旧 items + profitSharingRuleItemDao.delete(new QueryWrapper().eq("rule_id", rule.getId())); + + // 3. 批量插入新 items + List items = rule.getItems(); + if (items != null && !items.isEmpty()) { + for (ProfitSharingRuleItemEntity item : items) { + item.setRuleId(rule.getId()); + item.setCreatedAt(now); + item.setUpdatedAt(now); + profitSharingRuleItemDao.insert(item); + } + } + + // 4. 30%校验 + validateShareRatio(items); + } + + @Override + public void delete(Long id) { + ProfitSharingRuleEntity entity = new ProfitSharingRuleEntity(); + entity.setId(id); + entity.setDeleteFlag(DayCardConstants.DELETE_FLAG_DEL); + entity.setUpdatedAt(new Date()); + baseMapper.updateById(entity); + } + + @Override + public ProfitSharingRuleEntity matchRule(Long storeOrgId, Long agentOrgId, Long channelOrgId) { + // 1. 查 STORE 级规则 + if (storeOrgId != null) { + ProfitSharingRuleEntity rule = queryActiveRule(DayCardConstants.SCOPE_STORE, storeOrgId); + if (rule != null) { + fillItems(rule); + return rule; + } + } + + // 2. 未命中 -> 查 AGENT 级 + if (agentOrgId != null) { + ProfitSharingRuleEntity rule = queryActiveRule(DayCardConstants.SCOPE_AGENT, agentOrgId); + if (rule != null) { + fillItems(rule); + return rule; + } + } + + // 3. 未命中 -> 查 CHANNEL 级 + if (channelOrgId != null) { + ProfitSharingRuleEntity rule = queryActiveRule(DayCardConstants.SCOPE_CHANNEL, channelOrgId); + if (rule != null) { + fillItems(rule); + return rule; + } + } + + // 4. 未命中 -> 查 GLOBAL 级 + ProfitSharingRuleEntity rule = queryActiveRule(DayCardConstants.SCOPE_GLOBAL, null); + if (rule != null) { + fillItems(rule); + return rule; + } + + return null; + } + + /** + * 查询指定范围内生效的规则(单条,按 priority DESC 取最高) + */ + private ProfitSharingRuleEntity queryActiveRule(String scopeType, Long scopeOrgId) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("scope_type", scopeType) + .eq("status", DayCardConstants.PROFIT_RULE_STATUS_ACTIVE) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .and(w -> w.isNull("effective_from").or().le("effective_from", new Date())) + .and(w -> w.isNull("effective_to").or().ge("effective_to", new Date())) + .orderByDesc("priority") + .last("LIMIT 1"); + if (scopeOrgId != null) { + wrapper.eq("scope_org_id", scopeOrgId); + } else { + wrapper.isNull("scope_org_id"); + } + return baseMapper.selectOne(wrapper); + } + + /** + * 填充规则的 items 子表 + */ + private void fillItems(ProfitSharingRuleEntity rule) { + QueryWrapper itemWrapper = new QueryWrapper<>(); + itemWrapper.eq("rule_id", rule.getId()).orderByAsc("sort_order"); + List items = profitSharingRuleItemDao.selectList(itemWrapper); + rule.setItems(items); + } + + /** + * 30%校验:计算非平台方(orgRole != PLATFORM)的 RATIO 类型 shareValue 之和,超过30则抛异常 + */ + private void validateShareRatio(List items) { + if (items == null || items.isEmpty()) { + return; + } + BigDecimal sum = BigDecimal.ZERO; + for (ProfitSharingRuleItemEntity item : items) { + if (!DayCardConstants.ORG_ROLE_PLATFORM.equals(item.getOrgRole()) + && DayCardConstants.SHARE_TYPE_RATIO.equals(item.getShareType())) { + if (item.getShareValue() != null) { + sum = sum.add(item.getShareValue()); + } + } + } + if (sum.compareTo(BigDecimal.valueOf(DayCardConstants.PROFIT_SHARE_LIMIT)) > 0) { + throw new ASException("非平台方分润比例之和不能超过" + DayCardConstants.PROFIT_SHARE_LIMIT + "%"); + } + } +} diff --git a/src/main/java/io/as/modules/daycard/service/impl/SettleExecuteServiceImpl.java b/src/main/java/io/as/modules/daycard/service/impl/SettleExecuteServiceImpl.java new file mode 100644 index 0000000..040732c --- /dev/null +++ b/src/main/java/io/as/modules/daycard/service/impl/SettleExecuteServiceImpl.java @@ -0,0 +1,133 @@ +package io.as.modules.daycard.service.impl; + +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.dao.DayCardOrderDao; +import io.as.modules.daycard.entity.DayCardOrderEntity; +import io.as.modules.daycard.entity.DayCardSettleOrderEntity; +import io.as.modules.daycard.service.DayCardSettleOrderService; +import io.as.modules.daycard.service.ProfitCalcService; +import io.as.modules.daycard.service.SettleExecuteService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 分账执行 Service 实现 + */ +@Service("settleExecuteService") +public class SettleExecuteServiceImpl implements SettleExecuteService { + + private static final Logger logger = LoggerFactory.getLogger(SettleExecuteServiceImpl.class); + + @Autowired + private DayCardOrderDao dayCardOrderDao; + + @Autowired + private ProfitCalcService profitCalcService; + + @Autowired + private DayCardSettleOrderService dayCardSettleOrderService; + + @Override + public void executeSettle(String orderNo) { + // 1. 查询订单 + DayCardOrderEntity order = dayCardOrderDao.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("order_no", orderNo) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)); + if (order == null) { + logger.warn("分账执行失败:订单不存在, orderNo={}", orderNo); + return; + } + + // 幂等检查:已有分账单则跳过 + List existingOrders = dayCardSettleOrderService.getByOrderNo(orderNo); + if (existingOrders != null && !existingOrders.isEmpty()) { + logger.info("分账单已存在,跳过: orderNo={}", orderNo); + return; + } + + // 2. 调用 ProfitCalcService 构建分账单 + List settleOrders = profitCalcService.buildSettleOrders(orderNo, order); + if (settleOrders == null || settleOrders.isEmpty()) { + logger.info("无匹配的分润规则,跳过分账: orderNo={}", orderNo); + return; + } + + // 3. 保存分账单 + for (DayCardSettleOrderEntity settleOrder : settleOrders) { + dayCardSettleOrderService.saveSettleOrder(settleOrder); + } + logger.info("分账单已保存: orderNo={}, count={}", orderNo, settleOrders.size()); + + // 4. 逐条执行 + for (DayCardSettleOrderEntity settleOrder : settleOrders) { + try { + executeSingle(settleOrder); + } catch (Exception e) { + logger.error("执行分账单失败: settleNo={}", settleOrder.getSettleNo(), e); + dayCardSettleOrderService.updateStatus(settleOrder.getId(), + DayCardConstants.SETTLE_STATUS_FAILED, e.getMessage()); + } + } + } + + @Override + public void executeSingle(DayCardSettleOrderEntity settleOrder) { + // 设置状态为处理中 + dayCardSettleOrderService.updateStatus(settleOrder.getId(), + DayCardConstants.SETTLE_STATUS_PROCESSING, null); + + try { + // TODO: 实际调用微信分账API + // 当前为占位:模拟调用成功 + logger.info("执行分账(占位): settleNo={}, receiverAccount={}, settleAmount={}", + settleOrder.getSettleNo(), settleOrder.getReceiverAccount(), settleOrder.getSettleAmount()); + + // 模拟调用成功 + dayCardSettleOrderService.updateStatus(settleOrder.getId(), + DayCardConstants.SETTLE_STATUS_SUCCESS, null); + + logger.info("分账执行成功: settleNo={}", settleOrder.getSettleNo()); + } catch (Exception e) { + logger.error("分账执行失败: settleNo={}", settleOrder.getSettleNo(), e); + dayCardSettleOrderService.updateStatus(settleOrder.getId(), + DayCardConstants.SETTLE_STATUS_FAILED, e.getMessage()); + throw e; + } + } + + @Override + public int retryFailed() { + // 先重置失败的分账单状态 + int resetCount = dayCardSettleOrderService.retryFailed(); + if (resetCount == 0) { + return 0; + } + + logger.info("开始重试失败的分账单,共 {} 笔", resetCount); + + // 查询 PENDING 状态的分账单重新执行 + com.baomidou.mybatisplus.core.conditions.query.QueryWrapper wrapper = + new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>(); + wrapper.eq("status", DayCardConstants.SETTLE_STATUS_PENDING) + .last("LIMIT 200"); + List pendingList = dayCardSettleOrderService.list(wrapper); + + int retried = 0; + for (DayCardSettleOrderEntity settleOrder : pendingList) { + try { + executeSingle(settleOrder); + retried++; + } catch (Exception e) { + logger.error("重试分账单失败: settleNo={}", settleOrder.getSettleNo(), e); + } + } + + logger.info("重试分账单完成,成功 {} 笔", retried); + return retried; + } +} diff --git a/src/main/java/io/as/modules/daycard/task/DayCardActivateRetryTask.java b/src/main/java/io/as/modules/daycard/task/DayCardActivateRetryTask.java new file mode 100644 index 0000000..1911ea2 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/task/DayCardActivateRetryTask.java @@ -0,0 +1,32 @@ +package io.as.modules.daycard.task; + +import io.as.modules.daycard.service.DayCardOrderService; +import io.as.modules.job.task.ITask; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 天卡激活失败补偿定时任务 + *

定时扫描 activate_status=FAILED 的卡,重新激活

+ */ +@Component("dayCardActivateRetryTask") +public class DayCardActivateRetryTask implements ITask { + + private static final Logger logger = LoggerFactory.getLogger(DayCardActivateRetryTask.class); + + @Autowired + private DayCardOrderService dayCardOrderService; + + @Override + public void run(String params) { + logger.info("天卡激活失败补偿任务开始执行, params={}", params); + try { + int retried = dayCardOrderService.retryFailedActivations(); + logger.info("天卡激活失败补偿任务执行完毕, 重试激活数={}", retried); + } catch (Exception e) { + logger.error("天卡激活失败补偿任务执行异常", e); + } + } +} diff --git a/src/main/java/io/as/modules/daycard/task/DayCardOrderTimeoutTask.java b/src/main/java/io/as/modules/daycard/task/DayCardOrderTimeoutTask.java new file mode 100644 index 0000000..a9ecafe --- /dev/null +++ b/src/main/java/io/as/modules/daycard/task/DayCardOrderTimeoutTask.java @@ -0,0 +1,32 @@ +package io.as.modules.daycard.task; + +import io.as.modules.daycard.service.DayCardOrderService; +import io.as.modules.job.task.ITask; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 天卡订单超时取消定时任务 + *

定时扫描状态为 CREATED 且已过期的天卡订单,逐个取消并释放预留的卡

+ */ +@Component("dayCardOrderTimeoutTask") +public class DayCardOrderTimeoutTask implements ITask { + + private static final Logger logger = LoggerFactory.getLogger(DayCardOrderTimeoutTask.class); + + @Autowired + private DayCardOrderService dayCardOrderService; + + @Override + public void run(String params) { + logger.info("天卡订单超时取消任务开始执行, params={}", params); + try { + int cancelled = dayCardOrderService.cancelTimeoutOrders(); + logger.info("天卡订单超时取消任务执行完毕, 取消订单数={}", cancelled); + } catch (Exception e) { + logger.error("天卡订单超时取消任务执行异常", e); + } + } +} diff --git a/src/main/java/io/as/modules/daycard/task/ProfitSharingQueryTask.java b/src/main/java/io/as/modules/daycard/task/ProfitSharingQueryTask.java new file mode 100644 index 0000000..6a953de --- /dev/null +++ b/src/main/java/io/as/modules/daycard/task/ProfitSharingQueryTask.java @@ -0,0 +1,32 @@ +package io.as.modules.daycard.task; + +import io.as.modules.daycard.service.ProfitSharingExecuteService; +import io.as.modules.job.task.ITask; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 分账兜底查询定时任务 + *

定时扫描状态为 PENDING 或 FAILED 且超过5分钟的分账单,逐条重试

+ */ +@Component("profitSharingQueryTask") +public class ProfitSharingQueryTask implements ITask { + + private static final Logger logger = LoggerFactory.getLogger(ProfitSharingQueryTask.class); + + @Autowired + private ProfitSharingExecuteService profitSharingExecuteService; + + @Override + public void run(String params) { + logger.info("分账兜底查询任务开始执行, params={}", params); + try { + profitSharingExecuteService.retryFailed(); + logger.info("分账兜底查询任务执行完毕"); + } catch (Exception e) { + logger.error("分账兜底查询任务执行异常", e); + } + } +} diff --git a/src/main/java/io/as/modules/daycard/vo/DayCardOrderVo.java b/src/main/java/io/as/modules/daycard/vo/DayCardOrderVo.java new file mode 100644 index 0000000..60dc2e9 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/vo/DayCardOrderVo.java @@ -0,0 +1,27 @@ +package io.as.modules.daycard.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 天卡订单展示VO + */ +@Data +public class DayCardOrderVo implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private String orderNo; + private String status; + private BigDecimal amount; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date payTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; +} diff --git a/src/main/java/io/as/modules/daycard/vo/DayCardPoolStatsVo.java b/src/main/java/io/as/modules/daycard/vo/DayCardPoolStatsVo.java new file mode 100644 index 0000000..4e7df46 --- /dev/null +++ b/src/main/java/io/as/modules/daycard/vo/DayCardPoolStatsVo.java @@ -0,0 +1,22 @@ +package io.as.modules.daycard.vo; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 天卡池库存统计VO + */ +@Data +public class DayCardPoolStatsVo implements Serializable { + private static final long serialVersionUID = 1L; + + /** 总数 */ + private Integer total; + /** 可用(未使用) */ + private Integer available; + /** 已预留 */ + private Integer reserved; + /** 已使用 */ + private Integer used; +} diff --git a/src/main/java/io/as/modules/device/constants/DeviceConstants.java b/src/main/java/io/as/modules/device/constants/DeviceConstants.java new file mode 100755 index 0000000..126709d --- /dev/null +++ b/src/main/java/io/as/modules/device/constants/DeviceConstants.java @@ -0,0 +1,78 @@ +package io.as.modules.device.constants; + +/** + * 设备与卡片相关状态常量 + */ +public final class DeviceConstants { + + private DeviceConstants() { + } + + public static final String ORG_TYPE_STORE = "STORE"; + public static final String ORG_TYPE_SERVICE_PROVIDER = "SERVICE_PROVIDER"; + public static final String STATUS_ACTIVE = "ACTIVE"; + public static final String STATUS_DISABLED = "DISABLED"; + public static final String DELETE_FLAG_UNDEL = "UNDEL"; + /** 软删除标记 */ + public static final String DELETE_FLAG_DEL = "DEL"; + public static final String IN_STOCK = "IN_STOCK"; + public static final String SOLD = "SOLD"; + public static final String UNUSED = "UNUSED"; + public static final String BOUND = "BOUND"; + public static final String PRE_IMPORT = "PRE_IMPORT"; + public static final String ACTIVE_CODE = "ACTIVE_CODE"; + public static final String BIND_SOURCE_PRE_IMPORT = "PRE_IMPORT"; + public static final String BIND_SOURCE_ACTIVATION_CODE = "ACTIVATION_CODE"; + /** card_device_binding.status:当前生效绑定 */ + public static final String BIND_STATUS_BOUND = "BOUND"; + /** card_device_binding.status:已解绑 */ + public static final String BIND_STATUS_UNBOUND = "UNBOUND"; + /** card_device_binding.status:备用;无 BOUND 时与 BOUND 一样参与「有效绑定」查询(有过期清理) */ + public static final String BIND_STATUS_STANDBY = "STANDBY"; + /** card_device_binding.status:失效 */ + public static final String BIND_STATUS_FAILURE = "FAILURE"; + public static final String ALLOCATION_NONE = "NONE"; + public static final String USAGE_ACTIVATING = "ACTIVATING"; + public static final String USAGE_ACTIVATION_FAILED = "ACTIVATION_FAILED"; + public static final String USAGE_ACTIVE = "ACTIVE"; + public static final String USAGE_WAITING_MIGRATION = "WAITING_MIGRATION"; + public static final String USAGE_EXPIRED = "EXPIRED"; + /** 列表 bindCardStatus:与 usage_status 一致,待迁移单独展示,勿与「已激活」合并 */ + public static final String DISPLAY_WAITING_MIGRATION = "WAITING_MIGRATION"; + public static final String DISPLAY_UNBOUND = "UNBOUND"; + public static final String DISPLAY_PRESET = "PRESET"; + /** 列表:激活码等已绑定、卡仍为 BOUND(未走到 ACTIVE),非预置导入 */ + public static final String DISPLAY_PENDING_ACTIVATION = "PENDING_ACTIVATION"; + public static final String DISPLAY_ACTIVATING = "ACTIVATING"; + public static final String DISPLAY_ACTIVATION_FAILED = "ACTIVATION_FAILED"; + public static final String DISPLAY_ACTIVE = "ACTIVE"; + public static final String DISPLAY_EXPIRED = "EXPIRED"; + public static final String ACTION_TRANSFER = "TRANSFER"; + public static final String ACTION_UNBIND = "UNBIND"; + public static final String BIND_CARD_LOCK_PREFIX = "device:bind:card:"; + public static final long BIND_CARD_LOCK_EXPIRE = 30; + /** API 设备状态缓存前缀(与 ApiDeviceServiceImpl 保持一致) */ + public static final String API_DEVICE_STATUS_PREFIX = "api:device:status:"; + /** 临期年卡设备窗口天数 */ + public static final int EXPIRING_YEAR_DEVICE_WINDOW_DAYS = 30; + /** 未使用临期卡窗口天数(剩余激活时间) */ + public static final int NEAR_EXPIRY_UNUSED_CARD_WINDOW_DAYS = 31; + /** 仅允许通过「修改 MAC 并同步 third_card_no」接口改 MAC 的设备备注 */ + public static final String REMARK_MANUAL_DATA_MIGRATION = "开发人员手工迁移数据"; + /** 开发人员手工导入设备接口写入的设备备注 */ + public static final String REMARK_DEVELOPER_MANUAL_IMPORT = "开发人员手工导入"; + /** 「项目迁移」代理商组织 ID;其子树内门店可用手工导入接口 */ + public static final long[] PROJECT_MIGRATION_AGENT_ORG_IDS = { 168L, 621L }; + public static final String AUDIT_METHOD_DEVICE_UPDATE_MAC = "DEVICE_UPDATE_MAC_SYNC_THIRD"; + /** 设备标识策略 */ + public static final String DEVICE_MOD_MAC = "0"; + public static final String DEVICE_MOD_WIFI_MAC = "1"; + public static final String DEVICE_MOD_DEVICE_SN = "2"; + public static final String DEVICE_MOD_DEVICE_UID = "3"; + + /** + * 卡流转分配(/sys/card-flow/allocate、/app/activation-codes/allocate)选卡用:排除 third_card_no 含冒号(如 AA:BB:CC:DD:EE:FF),避免占用预置 MAC 类库存。 + */ + public static final String SQL_ALLOCATABLE_THIRD_CARD_NO_EXCLUDE_COLON = + "(third_card_no IS NULL OR third_card_no NOT LIKE '%:%')"; +} diff --git a/src/main/java/io/as/modules/device/controller/DeviceController.java b/src/main/java/io/as/modules/device/controller/DeviceController.java new file mode 100644 index 0000000..4a10f0d --- /dev/null +++ b/src/main/java/io/as/modules/device/controller/DeviceController.java @@ -0,0 +1,94 @@ +package io.as.modules.device.controller; + +import io.as.common.annotation.AuditAction; +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.modules.device.entity.DeviceEntity; +import io.as.modules.device.service.DeviceService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 终端管理 + */ +@Api(tags = "终端管理") +@RestController +@RequestMapping("/manage/device") +public class DeviceController { + + @Autowired + private DeviceService deviceService; + + /** + * 分页查询设备列表 + */ + @ApiOperation("分页查询设备列表") + @GetMapping("/list") + public R list(@RequestParam Map params) { + PageUtils page = deviceService.queryPage(params); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + /** + * 设备详情 + */ + @ApiOperation("设备详情") + @GetMapping("/info/{id}") + public R info(@PathVariable("id") Long id) { + DeviceEntity device = deviceService.getById(id); + Map data = new HashMap<>(); + data.put("device", device); + return R.dataSuccess(data); + } + + /** + * 新增设备 + */ + @ApiOperation("新增设备") + @AuditAction(value = "DEVICE_SAVE", description = "新增设备") + @PostMapping("/save") + public R save(@RequestBody DeviceEntity device) { + deviceService.saveDevice(device); + return R.ok(); + } + + /** + * 修改设备 + */ + @ApiOperation("修改设备") + @AuditAction(value = "DEVICE_UPDATE", description = "修改设备") + @PostMapping("/update") + public R update(@RequestBody DeviceEntity device) { + deviceService.updateDevice(device); + return R.ok(); + } + + /** + * 删除设备(软删除) + */ + @ApiOperation("删除设备") + @AuditAction(value = "DEVICE_DELETE", description = "删除设备") + @PostMapping("/delete") + public R delete(@RequestBody Long[] ids) { + deviceService.delete(ids); + return R.ok(); + } + + /** + * 绑定门店 + */ + @ApiOperation("绑定门店") + @AuditAction(value = "DEVICE_BIND_STORE", description = "设备绑定门店") + @PostMapping("/bindStore") + public R bindStore(@RequestParam("deviceId") Long deviceId, @RequestParam("storeOrgId") Long storeOrgId) { + deviceService.bindStore(deviceId, storeOrgId); + return R.ok(); + } +} diff --git a/src/main/java/io/as/modules/device/dao/CardDeviceBindingDao.java b/src/main/java/io/as/modules/device/dao/CardDeviceBindingDao.java new file mode 100755 index 0000000..dabe24b --- /dev/null +++ b/src/main/java/io/as/modules/device/dao/CardDeviceBindingDao.java @@ -0,0 +1,9 @@ +package io.as.modules.device.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.device.entity.CardDeviceBindingEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CardDeviceBindingDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/device/dao/DeviceDao.java b/src/main/java/io/as/modules/device/dao/DeviceDao.java new file mode 100755 index 0000000..5739df8 --- /dev/null +++ b/src/main/java/io/as/modules/device/dao/DeviceDao.java @@ -0,0 +1,59 @@ +package io.as.modules.device.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import io.as.modules.device.entity.DeviceEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.Date; +import java.util.List; + +/** + * 设备表 + */ +@Mapper +public interface DeviceDao extends BaseMapper { + + /** + * 设备分页:cardStatus 为 UNBOUND/未绑定、PRESET/预置 或 card.usage_status 枚举值 + */ + IPage selectDevicePage(Page page, + @Param("storeOrgIds") List storeOrgIds, + @Param("mac") String mac, + @Param("cardNo") String cardNo, + @Param("cardStatus") String cardStatus); + + /** + * 与 {@link #selectDevicePage} 相同筛选条件,不分页;用于 Excel 导出 + */ + List selectDeviceListForExport( + @Param("storeOrgIds") List storeOrgIds, + @Param("mac") String mac, + @Param("cardNo") String cardNo, + @Param("cardStatus") String cardStatus); + + /** + * 临期年卡设备:当前生效绑定为 BOUND 且年卡已激活、有效截止在 (now, windowEnd];已续费(存在未解绑 STANDBY)排除 + */ + IPage selectExpiringYearDevicePage(Page page, + @Param("storeOrgIds") List storeOrgIds, + @Param("now") Date now, + @Param("windowEnd") Date windowEnd); + + /** + * 同一 MAC 且 device_sn 在全表分组下出现多条时,返回未删除设备行; + * storeOrgIds 非空时:store_org_id 为空或在列表内;为空时仅 store_org_id 为空;可选 mac 模糊匹配 + */ + IPage selectDuplicateMacDeviceSnPage(Page page, + @Param("storeOrgIds") List storeOrgIds, + @Param("mac") String mac); + + /** + * 同一 MAC 在全表分组下出现多条时,返回未删除设备行;数据范围与 mac 过滤口径同 {@link #selectDuplicateMacDeviceSnPage} + */ + IPage selectDuplicateMacPage(Page page, + @Param("storeOrgIds") List storeOrgIds, + @Param("mac") String mac); +} diff --git a/src/main/java/io/as/modules/device/dto/DeviceOrgContext.java b/src/main/java/io/as/modules/device/dto/DeviceOrgContext.java new file mode 100644 index 0000000..b35e846 --- /dev/null +++ b/src/main/java/io/as/modules/device/dto/DeviceOrgContext.java @@ -0,0 +1,26 @@ +package io.as.modules.device.dto; + +import io.as.modules.device.entity.DeviceEntity; +import io.as.modules.sys.entity.SysOrgEntity; +import lombok.Data; + +import java.io.Serializable; + +/** + * 设备组织链路上下文 + */ +@Data +public class DeviceOrgContext implements Serializable { + private static final long serialVersionUID = 1L; + + /** 设备 */ + private DeviceEntity device; + /** 门店组织 */ + private SysOrgEntity storeOrg; + /** 渠道组织 */ + private SysOrgEntity channelOrg; + /** 代理商组织 */ + private SysOrgEntity agentOrg; + /** 合作伙伴ID(从设备关联获取,目前由 Controller 从签名上下文传入) */ + private Long partnerId; +} diff --git a/src/main/java/io/as/modules/device/entity/CardDeviceBindingEntity.java b/src/main/java/io/as/modules/device/entity/CardDeviceBindingEntity.java new file mode 100755 index 0000000..9b2a943 --- /dev/null +++ b/src/main/java/io/as/modules/device/entity/CardDeviceBindingEntity.java @@ -0,0 +1,35 @@ +package io.as.modules.device.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 卡与设备绑定(表 card_device_binding) + */ +@Data +@TableName("card_device_binding") +public class CardDeviceBindingEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + private Long cardId; + private Long deviceId; + private String bindSource; + private Date bindAt; + private Date unbindAt; + /** + * 绑定状态,取值见 {@link io.as.modules.device.constants.DeviceConstants#BIND_STATUS_BOUND}、 + * {@link io.as.modules.device.constants.DeviceConstants#BIND_STATUS_UNBOUND}、 + * {@link io.as.modules.device.constants.DeviceConstants#BIND_STATUS_STANDBY}、 + * {@link io.as.modules.device.constants.DeviceConstants#BIND_STATUS_FAILURE} + */ + private String status; + private Date createdAt; + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/device/entity/DeviceEntity.java b/src/main/java/io/as/modules/device/entity/DeviceEntity.java new file mode 100755 index 0000000..1fef21d --- /dev/null +++ b/src/main/java/io/as/modules/device/entity/DeviceEntity.java @@ -0,0 +1,106 @@ +package io.as.modules.device.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 设备表 + */ +@Data +@TableName("device") +public class DeviceEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + private Long storeOrgId; + private String mac; + private String deviceSn; + private String wifiMac; + private String deviceUid; + /** Android ID(重装不变,工厂重置才变) */ + private String androidId; + /** 设备厂商 Build.MANUFACTURER */ + private String manufacturer; + /** 设备型号 Build.MODEL */ + private String model; + /** 设备品牌 Build.BRAND */ + private String brand; + private String identifierStrategy; + private String deviceName; + private String deviceModel; + private String verCode; + private String verName; + private String androidVersion; + private String roomNo; + private Date lastHeartbeat; + private String remark; + private String status; + private String deleteFlag; + /** 设备来源: DIRECT(直销)/PARTNER(代销) */ + private String source; + /** 合作伙伴ID(代销模式) */ + private String partnerId; + private Date createdAt; + private Date updatedAt; + + /** 门店名称(非表字段,列表接口返回时填充) */ + @TableField(exist = false) + private String storeName; + + /** 所属渠道组织 ID(由门店 mpath 解析,非表字段) */ + @TableField(exist = false) + private Long channelOrgId; + /** 所属渠道名称(非表字段) */ + @TableField(exist = false) + private String channelName; + + /** + * 门店与渠道之间的代理商名称(非表字段);多级代理商以 「 / 」 连接,与组织 mpath 中 SERVICE_PROVIDER 段一致 + */ + @TableField(exist = false) + private String agentOrgName; + + /** 当前绑定卡号 card.card_no(非表字段,仅当有有效绑定时有值) */ + @TableField(exist = false) + private String bindCardNo; + /** 当前绑定对应的权益激活码 contract_activation_code.code(非表字段,预置绑定可为空) */ + @TableField(exist = false) + private String bindActivationCode; + /** + * 完整激活码:渠道 activationPrefix + contract_activation_code.code(非表字段); + * 与门店 mpath 第二段对应渠道上的前缀拼接,无任一部分则为 null + */ + @TableField(exist = false) + private String activeCode; + /** 当前绑定卡的 card.allocation_scene(非表字段,如 PRE_IMPORT、ACTIVE_CODE) */ + @TableField(exist = false) + private String bindAllocationScene; + + /** + * 当前绑定权益卡展示状态码(非表字段):UNBOUND 未绑定、PRESET 预置、ACTIVATING 激活中、 + * ACTIVATION_FAILED 激活失败、ACTIVE 已激活、EXPIRED 过期 + */ + @TableField(exist = false) + private String bindCardStatus; + /** 与 bindCardStatus 对应中文说明 */ + @TableField(exist = false) + private String bindCardStatusLabel; + /** 当前绑定卡的过期时间(优先 card.expire_at,否则 third_expire_at) */ + @TableField(exist = false) + private Date bindCardExpireAt; + + /** 当前绑定卡的激活时间 card.active_at(非表字段,仅当卡已激活时有值) */ + @TableField(exist = false) + private Date bindCardActiveAt; + + /** 当前绑定卡的预置时间 card_device_binding.created_at(非表字段,仅当为预置状态时有值) */ + @TableField(exist = false) + private Date bindCardPresetAt; +} diff --git a/src/main/java/io/as/modules/device/service/DeviceResolveService.java b/src/main/java/io/as/modules/device/service/DeviceResolveService.java new file mode 100644 index 0000000..52a0f9f --- /dev/null +++ b/src/main/java/io/as/modules/device/service/DeviceResolveService.java @@ -0,0 +1,28 @@ +package io.as.modules.device.service; + +import io.as.modules.device.dto.DeviceOrgContext; + +/** + * 设备识别服务:解析设备 → 门店 → 渠道 → 代理商的组织链路 + */ +public interface DeviceResolveService { + + /** + * 解析设备关联的组织链路 + * + * @param deviceId 设备ID + * @return 包含 device, storeOrg, channelOrg 的上下文 + */ + DeviceOrgContext resolveDevice(Long deviceId); + + /** + * 通过设备标识解析设备关联的组织链路 + *

按 MAC → SN → UID 优先级查询设备

+ * + * @param mac 设备MAC(优先) + * @param deviceSn 设备SN(次优) + * @param deviceUid 设备UID(兜底) + * @return 包含 device, storeOrg, channelOrg, agentOrg 的上下文 + */ + DeviceOrgContext resolveDevice(String mac, String deviceSn, String deviceUid); +} diff --git a/src/main/java/io/as/modules/device/service/DeviceService.java b/src/main/java/io/as/modules/device/service/DeviceService.java new file mode 100644 index 0000000..564e401 --- /dev/null +++ b/src/main/java/io/as/modules/device/service/DeviceService.java @@ -0,0 +1,58 @@ +package io.as.modules.device.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.device.entity.DeviceEntity; + +import java.util.Map; + +/** + * 设备管理 + */ +public interface DeviceService extends IService { + + /** + * 分页查询设备列表 + * + * @param params 查询参数(支持 storeOrgId/status/deviceName/mac 筛选) + * @return 分页结果 + */ + PageUtils queryPage(Map params); + + /** + * 根据ID获取设备详情 + * + * @param id 设备ID + * @return 设备实体,不存在或已删除返回 null + */ + DeviceEntity getById(Long id); + + /** + * 新增设备 + * + * @param device 设备实体 + */ + void saveDevice(DeviceEntity device); + + /** + * 修改设备 + * + * @param device 设备实体 + */ + void updateDevice(DeviceEntity device); + + /** + * 批量软删除设备 + * + * @param ids 设备ID数组 + */ + void delete(Long[] ids); + + /** + * 绑定门店 + * + * @param deviceId 设备ID + * @param storeOrgId 门店组织ID + */ + void bindStore(Long deviceId, Long storeOrgId); +} diff --git a/src/main/java/io/as/modules/device/service/impl/DeviceResolveServiceImpl.java b/src/main/java/io/as/modules/device/service/impl/DeviceResolveServiceImpl.java new file mode 100644 index 0000000..6c567f0 --- /dev/null +++ b/src/main/java/io/as/modules/device/service/impl/DeviceResolveServiceImpl.java @@ -0,0 +1,343 @@ +package io.as.modules.device.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import io.as.common.exception.ASException; +import io.as.modules.device.dao.DeviceDao; +import io.as.modules.device.dto.DeviceOrgContext; +import io.as.modules.device.entity.DeviceEntity; +import io.as.modules.device.service.DeviceResolveService; +import io.as.modules.sys.dao.SysOrgDao; +import io.as.modules.sys.entity.SysOrgEntity; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 设备识别服务实现:解析设备 → 门店 → 渠道 → 代理商的组织链路 + *

设备识别算法对齐生产环境 as-partner-backend 的 ApiDeviceServiceImpl

+ */ +@Service("deviceResolveService") +public class DeviceResolveServiceImpl implements DeviceResolveService { + + private static final Logger logger = LoggerFactory.getLogger(DeviceResolveServiceImpl.class); + + /** 占位 MAC:02:00:00:00:00:00 */ + private static final String PLACEHOLDER_MAC = "02:00:00:00:00:00"; + /** 设备未删除标记 */ + private static final String DELETE_FLAG_UNDEL = "UNDEL"; + /** 设备启用状态 */ + private static final String STATUS_ACTIVE = "ACTIVE"; + + /** identifier_strategy:0=MAC, 1=WiFi MAC, 2=deviceSn, 3=deviceUid, F=mac+sn+uid 全量匹配 */ + private static final String DEVICE_MOD_MAC = "0"; + private static final String DEVICE_MOD_WIFI_MAC = "1"; + private static final String DEVICE_MOD_DEVICE_SN = "2"; + private static final String DEVICE_MOD_DEVICE_UID = "3"; + private static final String DEVICE_MOD_FULL = "F"; + + @Autowired + private DeviceDao deviceDao; + + @Autowired + private SysOrgDao sysOrgDao; + + @Override + public DeviceOrgContext resolveDevice(Long deviceId) { + if (deviceId == null) { + throw new ASException("设备ID不能为空"); + } + + DeviceEntity device = deviceDao.selectById(deviceId); + if (device == null) { + throw new ASException("设备不存在"); + } + + DeviceOrgContext context = new DeviceOrgContext(); + context.setDevice(device); + resolveOrgChain(context, device); + return context; + } + + @Override + public DeviceOrgContext resolveDevice(String mac, String deviceSn, String deviceUid) { + DeviceResolutionResult resolutionResult = resolveDeviceInternal(mac, null, deviceSn, deviceUid); + DeviceEntity device = resolutionResult.getDevice(); + if (device == null) { + throw new ASException("设备未注册"); + } + + DeviceOrgContext context = new DeviceOrgContext(); + context.setDevice(device); + resolveOrgChain(context, device); + return context; + } + + /** + * 设备识别核心逻辑(对齐生产): + *
    + *
  • 占位 MAC(02:00:00:00:00:00):走 MAC → WiFiMAC → deviceSn → deviceUid 降级链
  • + *
  • 非占位 MAC:mac 必填且合法;SN/UID 均空找预置占位行;有值则精确匹配,无则回退预置占位行
  • + *
+ */ + public DeviceResolutionResult resolveDeviceInternal(String mac, String wifiMac, String deviceSn, String deviceUid) { + if (isPlaceholderMac(mac)) { + // 占位 MAC:降级链 MAC → WiFiMAC → deviceSn → deviceUid + return resolveDeviceWithFallback( + StringUtils.isNotBlank(mac) ? normalizeMac(mac) : null, + StringUtils.isNotBlank(wifiMac) ? normalizeMac(wifiMac) : null, + deviceSn, deviceUid); + } + + // 非占位 MAC:mac 必填 + String normalizedMac = normalizeMac(mac); + if (StringUtils.isBlank(normalizedMac)) { + return new DeviceResolutionResult(null, DEVICE_MOD_FULL); + } + + boolean presetPlaceholderInit = StringUtils.isBlank(deviceSn) && StringUtils.isBlank(deviceUid); + if (presetPlaceholderInit) { + // SN/UID 均空:找同 MAC 的预置占位行 + DeviceEntity presetRow = findPresetPlaceholderDevice(normalizedMac); + return new DeviceResolutionResult(presetRow, DEVICE_MOD_FULL); + } + + // SN/UID 有值:先精确匹配 mac+sn+uid + DeviceResolutionResult fullHit = resolveDeviceByMacSnUid(normalizedMac, deviceSn, deviceUid); + if (fullHit.getDevice() != null) { + return fullHit; + } + // 未命中则回退到预置占位行 + DeviceEntity presetRow = findPresetPlaceholderDevice(normalizedMac); + return new DeviceResolutionResult(presetRow, DEVICE_MOD_FULL); + } + + /** + * 按 mac + device_sn + device_uid 精确匹配(wifi_mac 不参与) + */ + private DeviceResolutionResult resolveDeviceByMacSnUid(String mac, String deviceSn, String deviceUid) { + if (StringUtils.isBlank(mac)) { + return new DeviceResolutionResult(null, DEVICE_MOD_FULL); + } + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("delete_flag", DELETE_FLAG_UNDEL) + .eq("mac", mac); + if (StringUtils.isNotBlank(deviceSn)) { + wrapper.eq("device_sn", deviceSn); + } + if (StringUtils.isNotBlank(deviceUid)) { + wrapper.eq("device_uid", deviceUid); + } + wrapper.orderByAsc("id").last("LIMIT 1"); + DeviceEntity device = deviceDao.selectOne(wrapper); + if (device != null && !isDeviceMatch(device, mac, deviceSn, deviceUid)) { + device = null; + } + return new DeviceResolutionResult(device, DEVICE_MOD_FULL); + } + + /** + * 预置占位设备:同 MAC 且 device_sn/device_uid 均未填的行 + */ + private DeviceEntity findPresetPlaceholderDevice(String mac) { + if (StringUtils.isBlank(mac)) { + return null; + } + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("mac", mac) + .eq("delete_flag", DELETE_FLAG_UNDEL) + .apply("(device_sn IS NULL OR device_sn = '') AND (device_uid IS NULL OR device_uid = '')") + .orderByAsc("id") + .last("LIMIT 1"); + return deviceDao.selectOne(wrapper); + } + + /** + * 降级策略查找设备(占位 MAC 场景):MAC → WiFiMAC → deviceSn → deviceUid + */ + private DeviceResolutionResult resolveDeviceWithFallback(String mac, String wifiMac, String deviceSn, String deviceUid) { + if (StringUtils.isNotBlank(mac)) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("mac", mac) + .eq("delete_flag", DELETE_FLAG_UNDEL) + .orderByAsc("id"); + List candidates = deviceDao.selectList(wrapper); + if (candidates != null && !candidates.isEmpty()) { + DeviceEntity device = pickDeviceFromCandidates(candidates, mac, deviceSn, deviceUid); + if (device != null) { + return new DeviceResolutionResult(device, DEVICE_MOD_MAC); + } + } + } + + if (StringUtils.isNotBlank(wifiMac)) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("wifi_mac", wifiMac) + .eq("delete_flag", DELETE_FLAG_UNDEL) + .orderByAsc("id"); + List candidates = deviceDao.selectList(wrapper); + if (candidates != null && !candidates.isEmpty()) { + DeviceEntity device = pickDeviceFromCandidates(candidates, null, deviceSn, deviceUid); + if (device != null) { + return new DeviceResolutionResult(device, DEVICE_MOD_WIFI_MAC); + } + } + } + + if (StringUtils.isNotBlank(deviceSn)) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("device_sn", deviceSn) + .eq("delete_flag", DELETE_FLAG_UNDEL) + .last("LIMIT 1"); + DeviceEntity device = deviceDao.selectOne(wrapper); + if (device != null && isDeviceMatch(device, null, deviceSn, deviceUid)) { + return new DeviceResolutionResult(device, DEVICE_MOD_DEVICE_SN); + } + } + + if (StringUtils.isNotBlank(deviceUid)) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("device_uid", deviceUid) + .eq("delete_flag", DELETE_FLAG_UNDEL) + .last("LIMIT 1"); + DeviceEntity device = deviceDao.selectOne(wrapper); + if (device != null && isDeviceMatch(device, null, deviceSn, deviceUid)) { + return new DeviceResolutionResult(device, DEVICE_MOD_DEVICE_UID); + } + } + + return new DeviceResolutionResult(null, DEVICE_MOD_MAC); + } + + /** + * 从同 MAC/WiFiMAC 多条候选中选行:优先完全匹配,否则取 SN/UID 均空的占位行 + */ + private DeviceEntity pickDeviceFromCandidates(List candidates, String mac, String deviceSn, String deviceUid) { + if (candidates == null || candidates.isEmpty()) { + return null; + } + for (DeviceEntity d : candidates) { + if (isDeviceMatch(d, mac, deviceSn, deviceUid)) { + return d; + } + } + for (DeviceEntity d : candidates) { + if (StringUtils.isBlank(d.getDeviceSn()) && StringUtils.isBlank(d.getDeviceUid())) { + return d; + } + } + return null; + } + + /** + * 二次校验:入参非空字段必须与设备记录一致 + */ + private boolean isDeviceMatch(DeviceEntity device, String mac, String deviceSn, String deviceUid) { + if (device == null) { + return false; + } + if (StringUtils.isNotBlank(mac) && !mac.equalsIgnoreCase(device.getMac())) { + return false; + } + if (StringUtils.isNotBlank(deviceSn) && !deviceSn.equals(device.getDeviceSn())) { + return false; + } + if (StringUtils.isNotBlank(deviceUid) && !deviceUid.equals(device.getDeviceUid())) { + return false; + } + return true; + } + + /** 判断是否占位 MAC */ + private boolean isPlaceholderMac(String mac) { + return mac != null && PLACEHOLDER_MAC.equalsIgnoreCase(mac.trim()); + } + + /** MAC 规范化:trim + 大写 */ + private static String normalizeMac(String mac) { + if (mac == null) { + return null; + } + String trimmed = mac.trim(); + return trimmed.isEmpty() ? null : trimmed.toUpperCase(); + } + + /** + * 解析组织链路:设备 → 门店 → 渠道 → 代理商 + */ + private void resolveOrgChain(DeviceOrgContext context, DeviceEntity device) { + Long storeOrgId = device.getStoreOrgId(); + if (storeOrgId == null) { + return; + } + SysOrgEntity storeOrg = sysOrgDao.selectById(storeOrgId); + if (storeOrg == null) { + return; + } + context.setStoreOrg(storeOrg); + context.setChannelOrg(resolveChannelOrg(storeOrg)); + context.setAgentOrg(resolveAgentOrg(storeOrg)); + } + + /** + * 从门店 mpath 解析渠道组织(mpath 第2段) + */ + private SysOrgEntity resolveChannelOrg(SysOrgEntity storeOrg) { + if (storeOrg == null || StringUtils.isBlank(storeOrg.getMpath())) { + return null; + } + try { + String[] segments = storeOrg.getMpath().split("\\."); + if (segments.length >= 2) { + Long channelOrgId = Long.parseLong(segments[1]); + return sysOrgDao.selectById(channelOrgId); + } + } catch (Exception e) { + logger.warn("解析渠道组织失败: storeOrgId={}, mpath={}", storeOrg.getId(), storeOrg.getMpath(), e); + } + return null; + } + + /** + * 从门店 mpath 解析代理商组织(mpath 第3段) + */ + private SysOrgEntity resolveAgentOrg(SysOrgEntity storeOrg) { + if (storeOrg == null || StringUtils.isBlank(storeOrg.getMpath())) { + return null; + } + try { + String[] segments = storeOrg.getMpath().split("\\."); + if (segments.length >= 3) { + Long agentOrgId = Long.parseLong(segments[2]); + return sysOrgDao.selectById(agentOrgId); + } + } catch (Exception e) { + logger.warn("解析代理商组织失败: storeOrgId={}, mpath={}", storeOrg.getId(), storeOrg.getMpath(), e); + } + return null; + } + + /** + * 设备解析结果(内部用) + */ + public static class DeviceResolutionResult { + private final DeviceEntity device; + private final String identifierStrategy; + + public DeviceResolutionResult(DeviceEntity device, String identifierStrategy) { + this.device = device; + this.identifierStrategy = identifierStrategy; + } + + public DeviceEntity getDevice() { + return device; + } + + public String getIdentifierStrategy() { + return identifierStrategy; + } + } +} diff --git a/src/main/java/io/as/modules/device/service/impl/DeviceServiceImpl.java b/src/main/java/io/as/modules/device/service/impl/DeviceServiceImpl.java new file mode 100644 index 0000000..f9e0356 --- /dev/null +++ b/src/main/java/io/as/modules/device/service/impl/DeviceServiceImpl.java @@ -0,0 +1,89 @@ +package io.as.modules.device.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.device.dao.DeviceDao; +import io.as.modules.device.entity.DeviceEntity; +import io.as.modules.device.service.DeviceService; +import org.apache.commons.lang.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.Map; + +/** + * 设备管理 + */ +@Service("deviceService") +public class DeviceServiceImpl extends ServiceImpl implements DeviceService { + + @Override + public PageUtils queryPage(Map params) { + String storeOrgId = (String) params.get("storeOrgId"); + String status = (String) params.get("status"); + String deviceName = (String) params.get("deviceName"); + String mac = (String) params.get("mac"); + + IPage page = this.page( + new Query().getPage(params, "created_at", false), + new QueryWrapper() + .eq(StringUtils.isNotBlank(storeOrgId), "store_org_id", storeOrgId) + .eq(StringUtils.isNotBlank(status), "status", status) + .like(StringUtils.isNotBlank(deviceName), "device_name", deviceName) + .like(StringUtils.isNotBlank(mac), "mac", mac) + .eq("delete_flag", "UNDEL") + ); + + return new PageUtils(page); + } + + @Override + public DeviceEntity getById(Long id) { + return this.getOne( + new QueryWrapper() + .eq("id", id) + .eq("delete_flag", "UNDEL") + ); + } + + @Override + public void saveDevice(DeviceEntity device) { + device.setDeleteFlag("UNDEL"); + device.setCreatedAt(new Date()); + device.setUpdatedAt(new Date()); + this.save(device); + } + + @Override + public void updateDevice(DeviceEntity device) { + if (device.getId() == null) { + throw new ASException("id不能为空"); + } + device.setUpdatedAt(new Date()); + this.updateById(device); + } + + @Override + public void delete(Long[] ids) { + for (Long id : ids) { + DeviceEntity device = new DeviceEntity(); + device.setId(id); + device.setDeleteFlag("DEL"); + device.setUpdatedAt(new Date()); + this.updateById(device); + } + } + + @Override + public void bindStore(Long deviceId, Long storeOrgId) { + DeviceEntity device = new DeviceEntity(); + device.setId(deviceId); + device.setStoreOrgId(storeOrgId); + device.setUpdatedAt(new Date()); + this.updateById(device); + } +} diff --git a/src/main/java/io/as/modules/job/config/ScheduleConfig.java b/src/main/java/io/as/modules/job/config/ScheduleConfig.java new file mode 100755 index 0000000..9be26e6 --- /dev/null +++ b/src/main/java/io/as/modules/job/config/ScheduleConfig.java @@ -0,0 +1,58 @@ +package io.as.modules.job.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; + +import javax.sql.DataSource; +import java.util.Properties; + +/** + * 定时任务配置 + * + * @author Mark sunlightcs@gmail.com + */ +@Configuration +public class ScheduleConfig { + + @Bean + public SchedulerFactoryBean schedulerFactoryBean(DataSource dataSource) { + SchedulerFactoryBean factory = new SchedulerFactoryBean(); + factory.setDataSource(dataSource); + + //quartz参数 + Properties prop = new Properties(); + prop.put("org.quartz.scheduler.instanceName", "RenrenScheduler"); + prop.put("org.quartz.scheduler.instanceId", "AUTO"); + //线程池配置 + prop.put("org.quartz.threadPool.class", "org.quartz.simpl.SimpleThreadPool"); + prop.put("org.quartz.threadPool.threadCount", "20"); + prop.put("org.quartz.threadPool.threadPriority", "5"); + //JobStore配置 + prop.put("org.quartz.jobStore.class", "org.springframework.scheduling.quartz.LocalDataSourceJobStore"); + //集群配置 + prop.put("org.quartz.jobStore.isClustered", "true"); + prop.put("org.quartz.jobStore.clusterCheckinInterval", "15000"); + prop.put("org.quartz.jobStore.maxMisfiresToHandleAtATime", "1"); + + prop.put("org.quartz.jobStore.misfireThreshold", "12000"); + prop.put("org.quartz.jobStore.tablePrefix", "QRTZ_"); + prop.put("org.quartz.jobStore.selectWithLockSQL", "SELECT * FROM {0}LOCKS UPDLOCK WHERE LOCK_NAME = ?"); + + //PostgreSQL数据库,需要打开此注释 + //prop.put("org.quartz.jobStore.driverDelegateClass", "org.quartz.impl.jdbcjobstore.PostgreSQLDelegate"); + + factory.setQuartzProperties(prop); + + factory.setSchedulerName("RenrenScheduler"); + //延时启动 + factory.setStartupDelay(30); + factory.setApplicationContextSchedulerContextKey("applicationContextKey"); + //可选,QuartzScheduler 启动时更新己存在的Job,这样就不用每次修改targetObject后删除qrtz_job_details表对应记录了 + factory.setOverwriteExistingJobs(true); + //设置自动启动,默认为true + factory.setAutoStartup(true); + + return factory; + } +} diff --git a/src/main/java/io/as/modules/job/dao/ScheduleJobDao.java b/src/main/java/io/as/modules/job/dao/ScheduleJobDao.java new file mode 100755 index 0000000..4d59bfc --- /dev/null +++ b/src/main/java/io/as/modules/job/dao/ScheduleJobDao.java @@ -0,0 +1,21 @@ +package io.as.modules.job.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.job.entity.ScheduleJobEntity; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Map; + +/** + * 定时任务 + * + * @author Mark sunlightcs@gmail.com + */ +@Mapper +public interface ScheduleJobDao extends BaseMapper { + + /** + * 批量更新状态 + */ + int updateBatch(Map map); +} diff --git a/src/main/java/io/as/modules/job/dao/ScheduleJobLogDao.java b/src/main/java/io/as/modules/job/dao/ScheduleJobLogDao.java new file mode 100755 index 0000000..47a01f6 --- /dev/null +++ b/src/main/java/io/as/modules/job/dao/ScheduleJobLogDao.java @@ -0,0 +1,15 @@ +package io.as.modules.job.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.job.entity.ScheduleJobLogEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 定时任务日志 + * + * @author Mark sunlightcs@gmail.com + */ +@Mapper +public interface ScheduleJobLogDao extends BaseMapper { + +} diff --git a/src/main/java/io/as/modules/job/dto/JobIdsRequest.java b/src/main/java/io/as/modules/job/dto/JobIdsRequest.java new file mode 100755 index 0000000..f15a262 --- /dev/null +++ b/src/main/java/io/as/modules/job/dto/JobIdsRequest.java @@ -0,0 +1,13 @@ +package io.as.modules.job.dto; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import lombok.Data; + +/** + * 定时任务批量操作请求体:兼容 JSON 数组 [1,2,3] 与对象 {"jobIds":[1,2,3]}。 + */ +@Data +@JsonDeserialize(using = ScheduleJobIdsDeserializer.class) +public class JobIdsRequest { + private Long[] jobIds; +} diff --git a/src/main/java/io/as/modules/job/dto/ScheduleJobIdsDeserializer.java b/src/main/java/io/as/modules/job/dto/ScheduleJobIdsDeserializer.java new file mode 100755 index 0000000..6390f90 --- /dev/null +++ b/src/main/java/io/as/modules/job/dto/ScheduleJobIdsDeserializer.java @@ -0,0 +1,58 @@ +package io.as.modules.job.dto; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; + +/** + * 将请求体反序列化为 {@link JobIdsRequest}:根可为 JSON 数组;或对象字段 jobIds(数组或单个数字)、ids、jobId、id。 + */ +public class ScheduleJobIdsDeserializer extends JsonDeserializer { + + @Override + public JobIdsRequest deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JobIdsRequest req = new JobIdsRequest(); + JsonToken token = p.getCurrentToken(); + if (token == JsonToken.START_ARRAY) { + req.setJobIds(ctxt.readValue(p, Long[].class)); + return req; + } + if (token == JsonToken.START_OBJECT) { + ObjectMapper mapper = (ObjectMapper) p.getCodec(); + JsonNode node = mapper.readTree(p); + JsonNode arr = node.get("jobIds"); + if (arr == null || arr.isNull()) { + arr = node.get("ids"); + } + if (arr != null && !arr.isMissingNode() && !arr.isNull()) { + if (arr.isArray()) { + req.setJobIds(mapper.convertValue(arr, Long[].class)); + return req; + } + if (arr.isNumber()) { + req.setJobIds(new Long[]{arr.longValue()}); + return req; + } + throw ctxt.mappingException("jobIds/ids 须为 JSON 数组或单个数字"); + } + JsonNode single = node.get("jobId"); + if (single == null || single.isNull()) { + single = node.get("id"); + } + if (single != null && !single.isMissingNode() && !single.isNull() && single.isNumber()) { + req.setJobIds(new Long[]{single.longValue()}); + return req; + } + throw ctxt.mappingException("请求体需为任务 ID 数组,例如 [1,2],或对象 {\"jobIds\":[1,2]},或单条 {\"jobId\":1}"); + } + if (token == JsonToken.VALUE_NULL) { + return null; + } + throw ctxt.mappingException("请求体需为任务 ID 数组,例如 [1,2],或对象 {\"jobIds\":[1,2]},或单条 {\"jobId\":1}"); + } +} diff --git a/src/main/java/io/as/modules/job/entity/ScheduleJobEntity.java b/src/main/java/io/as/modules/job/entity/ScheduleJobEntity.java new file mode 100755 index 0000000..9ee56c5 --- /dev/null +++ b/src/main/java/io/as/modules/job/entity/ScheduleJobEntity.java @@ -0,0 +1,66 @@ +package io.as.modules.job.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import java.io.Serializable; +import java.util.Date; + +/** + * 定时任务 + * + * @author Mark sunlightcs@gmail.com + */ +@Data +@TableName("schedule_job") +public class ScheduleJobEntity implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 任务调度参数key + */ + public static final String JOB_PARAM_KEY = "JOB_PARAM_KEY"; + + /** + * 任务id + */ + @TableId + private Long jobId; + + /** + * spring bean名称 + */ + @NotBlank(message="bean名称不能为空") + private String beanName; + + /** + * 参数 + */ + private String params; + + /** + * cron表达式 + */ + @NotBlank(message="cron表达式不能为空") + private String cronExpression; + + /** + * 任务状态 + */ + private Integer status; + + /** + * 备注 + */ + private String remark; + + /** + * 创建时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime; + +} diff --git a/src/main/java/io/as/modules/job/entity/ScheduleJobLogEntity.java b/src/main/java/io/as/modules/job/entity/ScheduleJobLogEntity.java new file mode 100755 index 0000000..e0bf459 --- /dev/null +++ b/src/main/java/io/as/modules/job/entity/ScheduleJobLogEntity.java @@ -0,0 +1,63 @@ +package io.as.modules.job.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 定时任务日志 + * + * @author Mark sunlightcs@gmail.com + */ +@Data +@TableName("schedule_job_log") +public class ScheduleJobLogEntity implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 日志id + */ + @TableId + private Long logId; + + /** + * 任务id + */ + private Long jobId; + + /** + * spring bean名称 + */ + private String beanName; + + /** + * 参数 + */ + private String params; + + /** + * 任务状态 0:成功 1:失败 + */ + private Integer status; + + /** + * 失败信息 + */ + private String error; + + /** + * 耗时(单位:毫秒) + */ + private Integer times; + + /** + * 创建时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime; + +} diff --git a/src/main/java/io/as/modules/job/service/ScheduleJobLogService.java b/src/main/java/io/as/modules/job/service/ScheduleJobLogService.java new file mode 100755 index 0000000..443d6fd --- /dev/null +++ b/src/main/java/io/as/modules/job/service/ScheduleJobLogService.java @@ -0,0 +1,18 @@ +package io.as.modules.job.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.job.entity.ScheduleJobLogEntity; + +import java.util.Map; + +/** + * 定时任务日志 + * + * @author Mark sunlightcs@gmail.com + */ +public interface ScheduleJobLogService extends IService { + + PageUtils queryPage(Map params); + +} diff --git a/src/main/java/io/as/modules/job/service/ScheduleJobService.java b/src/main/java/io/as/modules/job/service/ScheduleJobService.java new file mode 100755 index 0000000..0de9b01 --- /dev/null +++ b/src/main/java/io/as/modules/job/service/ScheduleJobService.java @@ -0,0 +1,52 @@ +package io.as.modules.job.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.job.entity.ScheduleJobEntity; + +import java.util.Map; + +/** + * 定时任务 + * + * @author Mark sunlightcs@gmail.com + */ +public interface ScheduleJobService extends IService { + + PageUtils queryPage(Map params); + + /** + * 保存定时任务 + */ + void saveJob(ScheduleJobEntity scheduleJob); + + /** + * 更新定时任务 + */ + void update(ScheduleJobEntity scheduleJob); + + /** + * 批量删除定时任务 + */ + void deleteBatch(Long[] jobIds); + + /** + * 批量更新定时任务状态 + */ + int updateBatch(Long[] jobIds, int status); + + /** + * 立即执行 + */ + void run(Long[] jobIds); + + /** + * 暂停运行 + */ + void pause(Long[] jobIds); + + /** + * 恢复运行 + */ + void resume(Long[] jobIds); +} diff --git a/src/main/java/io/as/modules/job/service/impl/ScheduleJobLogServiceImpl.java b/src/main/java/io/as/modules/job/service/impl/ScheduleJobLogServiceImpl.java new file mode 100755 index 0000000..794e954 --- /dev/null +++ b/src/main/java/io/as/modules/job/service/impl/ScheduleJobLogServiceImpl.java @@ -0,0 +1,31 @@ +package io.as.modules.job.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.job.dao.ScheduleJobLogDao; +import io.as.modules.job.entity.ScheduleJobLogEntity; +import io.as.modules.job.service.ScheduleJobLogService; +import org.apache.commons.lang.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service("scheduleJobLogService") +public class ScheduleJobLogServiceImpl extends ServiceImpl implements ScheduleJobLogService { + + @Override + public PageUtils queryPage(Map params) { + String jobId = (String)params.get("jobId"); + + IPage page = this.page( + new Query().getPage(params), + new QueryWrapper().like(StringUtils.isNotBlank(jobId),"job_id", jobId) + ); + + return new PageUtils(page); + } + +} diff --git a/src/main/java/io/as/modules/job/service/impl/ScheduleJobServiceImpl.java b/src/main/java/io/as/modules/job/service/impl/ScheduleJobServiceImpl.java new file mode 100755 index 0000000..12e0dfd --- /dev/null +++ b/src/main/java/io/as/modules/job/service/impl/ScheduleJobServiceImpl.java @@ -0,0 +1,123 @@ +package io.as.modules.job.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.utils.Constant; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.job.dao.ScheduleJobDao; +import io.as.modules.job.entity.ScheduleJobEntity; +import io.as.modules.job.service.ScheduleJobService; +import io.as.modules.job.utils.ScheduleUtils; +import org.apache.commons.lang.StringUtils; +import org.quartz.CronTrigger; +import org.quartz.Scheduler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.PostConstruct; +import java.util.*; + +@Service("scheduleJobService") +public class ScheduleJobServiceImpl extends ServiceImpl implements ScheduleJobService { + @Autowired + private Scheduler scheduler; + + /** + * 项目启动时,初始化定时器 + */ + @PostConstruct + public void init(){ + List scheduleJobList = this.list(); + for(ScheduleJobEntity scheduleJob : scheduleJobList){ + CronTrigger cronTrigger = ScheduleUtils.getCronTrigger(scheduler, scheduleJob.getJobId()); + //如果不存在,则创建 + if(cronTrigger == null) { + ScheduleUtils.createScheduleJob(scheduler, scheduleJob); + }else { + ScheduleUtils.updateScheduleJob(scheduler, scheduleJob); + } + } + } + + @Override + public PageUtils queryPage(Map params) { + String beanName = (String)params.get("beanName"); + + IPage page = this.page( + new Query().getPage(params), + new QueryWrapper ().like(StringUtils.isNotBlank(beanName),"bean_name", beanName) + ); + + return new PageUtils(page); + } + + + @Override + @Transactional(rollbackFor = Exception.class) + public void saveJob(ScheduleJobEntity scheduleJob) { + scheduleJob.setCreateTime(new Date()); + scheduleJob.setStatus(Constant.ScheduleStatus.NORMAL.getValue()); + this.save(scheduleJob); + + ScheduleUtils.createScheduleJob(scheduler, scheduleJob); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void update(ScheduleJobEntity scheduleJob) { + ScheduleUtils.updateScheduleJob(scheduler, scheduleJob); + + this.updateById(scheduleJob); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteBatch(Long[] jobIds) { + for(Long jobId : jobIds){ + ScheduleUtils.deleteScheduleJob(scheduler, jobId); + } + + //删除数据 + this.removeByIds(Arrays.asList(jobIds)); + } + + @Override + public int updateBatch(Long[] jobIds, int status){ + Map map = new HashMap<>(2); + map.put("list", Arrays.asList(jobIds)); + map.put("status", status); + return baseMapper.updateBatch(map); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void run(Long[] jobIds) { + for(Long jobId : jobIds){ + ScheduleUtils.run(scheduler, this.getById(jobId)); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void pause(Long[] jobIds) { + for(Long jobId : jobIds){ + ScheduleUtils.pauseJob(scheduler, jobId); + } + + updateBatch(jobIds, Constant.ScheduleStatus.PAUSE.getValue()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void resume(Long[] jobIds) { + for(Long jobId : jobIds){ + ScheduleUtils.resumeJob(scheduler, jobId); + } + + updateBatch(jobIds, Constant.ScheduleStatus.NORMAL.getValue()); + } + +} diff --git a/src/main/java/io/as/modules/job/task/ITask.java b/src/main/java/io/as/modules/job/task/ITask.java new file mode 100755 index 0000000..7c606b2 --- /dev/null +++ b/src/main/java/io/as/modules/job/task/ITask.java @@ -0,0 +1,16 @@ +package io.as.modules.job.task; + +/** + * 定时任务接口,所有定时任务都要实现该接口 + * + * @author Mark sunlightcs@gmail.com + */ +public interface ITask { + + /** + * 执行定时任务接口 + * + * @param params 参数,多参数使用JSON数据 + */ + void run(String params); +} \ No newline at end of file diff --git a/src/main/java/io/as/modules/job/utils/ScheduleJob.java b/src/main/java/io/as/modules/job/utils/ScheduleJob.java new file mode 100755 index 0000000..c11fb45 --- /dev/null +++ b/src/main/java/io/as/modules/job/utils/ScheduleJob.java @@ -0,0 +1,73 @@ +package io.as.modules.job.utils; + +import io.as.common.utils.SpringContextUtils; +import io.as.modules.job.entity.ScheduleJobEntity; +import io.as.modules.job.entity.ScheduleJobLogEntity; +import io.as.modules.job.service.ScheduleJobLogService; +import org.apache.commons.lang.StringUtils; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.quartz.QuartzJobBean; + +import java.lang.reflect.Method; +import java.util.Date; + + +/** + * 定时任务 + * + * @author Mark sunlightcs@gmail.com + */ +public class ScheduleJob extends QuartzJobBean { + private Logger logger = LoggerFactory.getLogger(getClass()); + + @Override + protected void executeInternal(JobExecutionContext context) throws JobExecutionException { + ScheduleJobEntity scheduleJob = (ScheduleJobEntity) context.getMergedJobDataMap() + .get(ScheduleJobEntity.JOB_PARAM_KEY); + + //获取spring bean + ScheduleJobLogService scheduleJobLogService = (ScheduleJobLogService) SpringContextUtils.getBean("scheduleJobLogService"); + + //数据库保存执行记录 + ScheduleJobLogEntity log = new ScheduleJobLogEntity(); + log.setJobId(scheduleJob.getJobId()); + log.setBeanName(scheduleJob.getBeanName()); + log.setParams(scheduleJob.getParams()); + log.setCreateTime(new Date()); + + //任务开始时间 + long startTime = System.currentTimeMillis(); + + try { + //执行任务 + logger.debug("任务准备执行,任务ID:" + scheduleJob.getJobId()); + + Object target = SpringContextUtils.getBean(scheduleJob.getBeanName()); + Method method = target.getClass().getDeclaredMethod("run", String.class); + method.invoke(target, scheduleJob.getParams()); + + //任务执行总时长 + long times = System.currentTimeMillis() - startTime; + log.setTimes((int)times); + //任务状态 0:成功 1:失败 + log.setStatus(0); + + logger.debug("任务执行完毕,任务ID:" + scheduleJob.getJobId() + " 总共耗时:" + times + "毫秒"); + } catch (Exception e) { + logger.error("任务执行失败,任务ID:" + scheduleJob.getJobId(), e); + + //任务执行总时长 + long times = System.currentTimeMillis() - startTime; + log.setTimes((int)times); + + //任务状态 0:成功 1:失败 + log.setStatus(1); + log.setError(StringUtils.substring(e.toString(), 0, 2000)); + }finally { + scheduleJobLogService.save(log); + } + } +} diff --git a/src/main/java/io/as/modules/job/utils/ScheduleUtils.java b/src/main/java/io/as/modules/job/utils/ScheduleUtils.java new file mode 100755 index 0000000..62befac --- /dev/null +++ b/src/main/java/io/as/modules/job/utils/ScheduleUtils.java @@ -0,0 +1,148 @@ +package io.as.modules.job.utils; + +import io.as.common.exception.ASException; +import io.as.common.utils.Constant; +import io.as.modules.job.entity.ScheduleJobEntity; +import org.quartz.*; + +/** + * 定时任务工具类 + * + * @author Mark sunlightcs@gmail.com + */ +public class ScheduleUtils { + private final static String JOB_NAME = "TASK_"; + + /** + * 获取触发器key + */ + public static TriggerKey getTriggerKey(Long jobId) { + return TriggerKey.triggerKey(JOB_NAME + jobId); + } + + /** + * 获取jobKey + */ + public static JobKey getJobKey(Long jobId) { + return JobKey.jobKey(JOB_NAME + jobId); + } + + /** + * 获取表达式触发器 + */ + public static CronTrigger getCronTrigger(Scheduler scheduler, Long jobId) { + try { + return (CronTrigger) scheduler.getTrigger(getTriggerKey(jobId)); + } catch (SchedulerException e) { + throw new ASException("获取定时任务CronTrigger出现异常", e); + } + } + + /** + * 创建定时任务 + */ + public static void createScheduleJob(Scheduler scheduler, ScheduleJobEntity scheduleJob) { + try { + //构建job信息 + JobDetail jobDetail = JobBuilder.newJob(ScheduleJob.class).withIdentity(getJobKey(scheduleJob.getJobId())).build(); + + //表达式调度构建器 + CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.cronSchedule(scheduleJob.getCronExpression()) + .withMisfireHandlingInstructionDoNothing(); + + //按新的cronExpression表达式构建一个新的trigger + CronTrigger trigger = TriggerBuilder.newTrigger().withIdentity(getTriggerKey(scheduleJob.getJobId())).withSchedule(scheduleBuilder).build(); + + //放入参数,运行时的方法可以获取 + jobDetail.getJobDataMap().put(ScheduleJobEntity.JOB_PARAM_KEY, scheduleJob); + + scheduler.scheduleJob(jobDetail, trigger); + + //暂停任务 + if(scheduleJob.getStatus() == Constant.ScheduleStatus.PAUSE.getValue()){ + pauseJob(scheduler, scheduleJob.getJobId()); + } + } catch (SchedulerException e) { + throw new ASException("创建定时任务失败", e); + } + } + + /** + * 更新定时任务 + */ + public static void updateScheduleJob(Scheduler scheduler, ScheduleJobEntity scheduleJob) { + try { + TriggerKey triggerKey = getTriggerKey(scheduleJob.getJobId()); + + //表达式调度构建器 + CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.cronSchedule(scheduleJob.getCronExpression()) + .withMisfireHandlingInstructionDoNothing(); + + CronTrigger trigger = getCronTrigger(scheduler, scheduleJob.getJobId()); + + //按新的cronExpression表达式重新构建trigger + trigger = trigger.getTriggerBuilder().withIdentity(triggerKey).withSchedule(scheduleBuilder).build(); + + //参数 + trigger.getJobDataMap().put(ScheduleJobEntity.JOB_PARAM_KEY, scheduleJob); + + scheduler.rescheduleJob(triggerKey, trigger); + + //暂停任务 + if(scheduleJob.getStatus() == Constant.ScheduleStatus.PAUSE.getValue()){ + pauseJob(scheduler, scheduleJob.getJobId()); + } + + } catch (SchedulerException e) { + throw new ASException("更新定时任务失败", e); + } + } + + /** + * 立即执行任务 + */ + public static void run(Scheduler scheduler, ScheduleJobEntity scheduleJob) { + try { + //参数 + JobDataMap dataMap = new JobDataMap(); + dataMap.put(ScheduleJobEntity.JOB_PARAM_KEY, scheduleJob); + + scheduler.triggerJob(getJobKey(scheduleJob.getJobId()), dataMap); + } catch (SchedulerException e) { + throw new ASException("立即执行定时任务失败", e); + } + } + + /** + * 暂停任务 + */ + public static void pauseJob(Scheduler scheduler, Long jobId) { + try { + scheduler.pauseJob(getJobKey(jobId)); + } catch (SchedulerException e) { + throw new ASException("暂停定时任务失败", e); + } + } + + /** + * 恢复任务 + */ + public static void resumeJob(Scheduler scheduler, Long jobId) { + try { + scheduler.resumeJob(getJobKey(jobId)); + } catch (SchedulerException e) { + throw new ASException("暂停定时任务失败", e); + } + } + + /** + * 删除定时任务 + */ + public static void deleteScheduleJob(Scheduler scheduler, Long jobId) { + try { + scheduler.deleteJob(getJobKey(jobId)); + } catch (SchedulerException e) { + throw new ASException("删除定时任务失败", e); + } + } +} diff --git a/src/main/java/io/as/modules/jst/config/AsyncConfig.java b/src/main/java/io/as/modules/jst/config/AsyncConfig.java new file mode 100755 index 0000000..b3c7cee --- /dev/null +++ b/src/main/java/io/as/modules/jst/config/AsyncConfig.java @@ -0,0 +1,30 @@ +package io.as.modules.jst.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 异步任务配置:用于 activate 等需后台执行的第三方调用 + */ +@Configuration +public class AsyncConfig { + + /** + * 第三方激活专用线程池:核心 2,最大 8,队列 100 + */ + @Bean("thirdPartyActivateExecutor") + public TaskExecutor thirdPartyActivateExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("thirdparty-activate-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/io/as/modules/jst/config/JstPlatformConfig.java b/src/main/java/io/as/modules/jst/config/JstPlatformConfig.java new file mode 100755 index 0000000..e172f49 --- /dev/null +++ b/src/main/java/io/as/modules/jst/config/JstPlatformConfig.java @@ -0,0 +1,34 @@ +package io.as.modules.jst.config; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 聚视厅/橘视厅第三方平台配置(存于 sys_config,param_key=JST_PLATFORM_CONFIG,param_value 为 JSON) + */ +@Data +public class JstPlatformConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 橘视厅 base URL */ + private String jushitingBaseUrl = ""; + /** 橘视厅 tenant,固定 huashitv */ + private String jushitingTenant = "huashitv"; + /** 合作方标识 accesser,线下分配 */ + private String jushitingAccesser = ""; + /** 业务线 businessLine,线下分配 */ + private String jushitingBusinessLine = ""; + /** 业务组合 serviceComboCode */ + private String jushitingServiceComboCode = "0"; + /** 免密登录 RSA 公钥(Base64 X.509/SPKI),用于加密待签名字符串,线下获取 */ + private String jushitingLoginRsaPublicKey = ""; + /** 卡密激活 sign 的 key,线下分配 */ + private String jushitingActivateSignKey = ""; + + /** 会员到期时间接口 base URL */ + private String vipQueryBaseUrl = ""; + /** 会员到期时间接口 sign 的 key */ + private String vipQuerySignKey = "c25svtwbTyCE"; +} diff --git a/src/main/java/io/as/modules/jst/dao/ApiJstCallLogDao.java b/src/main/java/io/as/modules/jst/dao/ApiJstCallLogDao.java new file mode 100755 index 0000000..70bde94 --- /dev/null +++ b/src/main/java/io/as/modules/jst/dao/ApiJstCallLogDao.java @@ -0,0 +1,31 @@ +package io.as.modules.jst.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import io.as.modules.jst.entity.ApiJstCallLogEntity; +import io.as.modules.sys.vo.ApiJstCallLogPageRowVo; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.Date; + +/** + * 橘视厅/聚视厅第三方 HTTP 出站调用日志 {@code api_jst_call_log} + */ +@Mapper +public interface ApiJstCallLogDao extends BaseMapper { + + /** + * 分页列表(含设备 MAC/SN/UID、门店组织)。 + *

{@code mac}:设备 MAC / 请求体 / 请求头模糊;{@code apiPathKw}:完整 URL/path 关键字;{@code bodyKw}:请求体关键字; + * {@code beginTime}/{@code endTime}:创建时间闭区间边界(由 Service 解析)。

+ */ + IPage selectJstCallLogPage( + Page page, + @Param("mac") String mac, + @Param("beginTime") Date beginTime, + @Param("endTime") Date endTime, + @Param("apiPathKw") String apiPathKw, + @Param("bodyKw") String bodyKw); +} diff --git a/src/main/java/io/as/modules/jst/entity/ApiJstCallLogEntity.java b/src/main/java/io/as/modules/jst/entity/ApiJstCallLogEntity.java new file mode 100755 index 0000000..a7b3265 --- /dev/null +++ b/src/main/java/io/as/modules/jst/entity/ApiJstCallLogEntity.java @@ -0,0 +1,46 @@ +package io.as.modules.jst.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 服务端调用橘视厅(JST)等第三方 HTTP 的 outbound 日志(非设备调本系统 /api/jst 的入站日志) + */ +@Data +@TableName("api_jst_call_log") +public class ApiJstCallLogEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 第三方请求的完整 URL(scheme/host/path/query,落库截断至 2000,见 JushitingApiServiceImpl) */ + private String apiPath; + /** 请求方法 POST */ + private String requestMethod; + /** 请求体 JSON */ + private String requestBody; + /** 响应业务码 200/400/500 */ + private Integer responseCode; + /** 响应体 JSON */ + private String responseBody; + /** 是否成功 0失败 1成功 */ + private Integer success; + /** 耗时毫秒 */ + private Integer duration; + /** data_id:记录 device.id,用于关联设备数据 */ + private Long dataId; + /** 请求自定义 header(JSON 格式) */ + private String requestHeaders; + /** 客户端 IP */ + private String clientIp; + /** 错误信息 */ + private String errorMessage; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime; +} diff --git a/src/main/java/io/as/modules/jst/service/ApiJstCallLogService.java b/src/main/java/io/as/modules/jst/service/ApiJstCallLogService.java new file mode 100755 index 0000000..d253062 --- /dev/null +++ b/src/main/java/io/as/modules/jst/service/ApiJstCallLogService.java @@ -0,0 +1,32 @@ +package io.as.modules.jst.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.jst.entity.ApiJstCallLogEntity; + +import java.util.Map; + +/** + * 调用橘视厅等第三方 HTTP 时的 outbound 日志({@code api_jst_call_log}),由 {@link io.as.modules.jst.service.impl.JushitingApiServiceImpl} 写入 + */ +public interface ApiJstCallLogService extends IService { + + /** + * 分页查询。 + *

可选条件:mac(大写归一);创建时间起点 beginTime、startTime、beginCreatedAt、start_time、begin_time + * 与终点 endTime、endCreatedAt、end_created_at、end_time(yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss;仅日期时结束含当日最后一刻); + * 接口路径(完整 URL)模糊 apiPath、path、api_path;请求体模糊 requestBody、body、request_body。

+ */ + PageUtils queryPage(Map params); + + /** + * 在新事务中保存日志,主业务回滚时不会被回滚 + * @return 保存后的实体(含 log_id) + */ + ApiJstCallLogEntity saveInNewTx(ApiJstCallLogEntity log); + + /** + * 在新事务中更新日志,主业务回滚时不会被回滚 + */ + void updateInNewTx(ApiJstCallLogEntity log); +} diff --git a/src/main/java/io/as/modules/jst/service/JstActivateCardResult.java b/src/main/java/io/as/modules/jst/service/JstActivateCardResult.java new file mode 100755 index 0000000..86070ee --- /dev/null +++ b/src/main/java/io/as/modules/jst/service/JstActivateCardResult.java @@ -0,0 +1,48 @@ +package io.as.modules.jst.service; + +/** + * 橘视厅卡密激活结果:成功时可能携带 response.retInfo 中的 {@code expireTime}(如 {@code 2027-04-24 16:48:58}), + * 与 {@link JushitingApiService#activateCard} 的 HTTP 体一致。 + */ +public final class JstActivateCardResult { + + private final boolean success; + /** 成功时对应第三方 retInfo.expireTime,未返回则为 null;失败必为 null */ + private final String expireTime; + /** 失败时的错误信息 */ + private final String errorMessage; + + private JstActivateCardResult(boolean success, String expireTime, String errorMessage) { + this.success = success; + this.expireTime = expireTime; + this.errorMessage = errorMessage; + } + + public static JstActivateCardResult failure() { + return new JstActivateCardResult(false, null, null); + } + + public static JstActivateCardResult failure(String errorMessage) { + return new JstActivateCardResult(false, null, errorMessage); + } + + /** + * @param expireTime 可为 null,表示激活成功但响应中未带到期时间,调用方可再查 + * {@link JushitingApiService#queryVipExpireTime} + */ + public static JstActivateCardResult success(String expireTime) { + return new JstActivateCardResult(true, expireTime, null); + } + + public boolean isSuccess() { + return success; + } + + public String getExpireTime() { + return expireTime; + } + + public String getErrorMessage() { + return errorMessage; + } +} diff --git a/src/main/java/io/as/modules/jst/service/JushitingApiService.java b/src/main/java/io/as/modules/jst/service/JushitingApiService.java new file mode 100755 index 0000000..dbbdf90 --- /dev/null +++ b/src/main/java/io/as/modules/jst/service/JushitingApiService.java @@ -0,0 +1,39 @@ +package io.as.modules.jst.service; + +import io.as.modules.device.entity.DeviceEntity; + +import java.util.Map; + +/** + * 橘视厅/会员第三方接口调用 + *

三步:获取 Token → 卡密激活(retInfo 常含 expireTime)→ 无则再查会员到期时间

+ */ +public interface JushitingApiService { + + /** + * 第一步:免密登录获取 Token + * + * @param device 设备(提供 mac、deviceUid 等) + * @param thirdCardRef 第三方设备ID + * @return 成功时返回含 token、memberCode 的 Map;失败抛异常或返回 null,调用方看日志 + */ + Map getToken(DeviceEntity device,String thirdCardRef); + + /** + * 第二步:卡密激活 + * + * @param token 免密登录返回的 token + * @return 失败时 {@link JstActivateCardResult#isSuccess()} 为 false;成功时可能含 + * {@link JstActivateCardResult#getExpireTime()}(对应 retInfo.expireTime) + */ + JstActivateCardResult activateCard(Long deviceId, String activateCode, String memberCode, String token); + + /** + * 第三步:查询会员到期时间(用于校验激活是否生效) + * + * @param deviceId 设备 ID,写入第三方调用日志 data_id + * @param uid 三方用户唯一标识(与 getToken 的 uid 一致,如 third_card_no) + * @return 成功时返回 vipExpireTime 字符串;失败返回 null + */ + String queryVipExpireTime(Long deviceId, String uid); +} diff --git a/src/main/java/io/as/modules/jst/service/impl/ApiJstCallLogServiceImpl.java b/src/main/java/io/as/modules/jst/service/impl/ApiJstCallLogServiceImpl.java new file mode 100755 index 0000000..8881431 --- /dev/null +++ b/src/main/java/io/as/modules/jst/service/impl/ApiJstCallLogServiceImpl.java @@ -0,0 +1,140 @@ +package io.as.modules.jst.service.impl; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.OrderItem; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.exception.ASException; +import io.as.common.utils.Constant; +import io.as.common.utils.DateUtils; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.jst.service.ApiJstCallLogService; +import io.as.modules.jst.dao.ApiJstCallLogDao; +import io.as.modules.jst.entity.ApiJstCallLogEntity; +import io.as.modules.sys.utils.DeviceImportUtils; +import io.as.modules.sys.vo.ApiJstCallLogPageRowVo; +import org.apache.commons.lang.StringUtils; +import org.joda.time.DateTime; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.Date; +import java.util.Map; + +/** + * {@code api_jst_call_log}:第三方 outbound 调用落库 + */ +@Service("apiJstCallLogService") +public class ApiJstCallLogServiceImpl extends ServiceImpl implements ApiJstCallLogService { + + @Override + public PageUtils queryPage(Map params) { + Date beginTime = resolveCreateTimeBegin(params); + Date endTime = resolveCreateTimeEnd(params); + if (beginTime != null && endTime != null && beginTime.after(endTime)) { + throw new ASException("开始时间不能晚于结束时间"); + } + + String mac = DeviceImportUtils.normalizeMac(DeviceImportUtils.firstParamString(params.get("mac"))); + String apiPathKw = StringUtils.defaultString(firstParamFromMap(params, "apiPath", "path", "api_path"), ""); + String bodyKw = StringUtils.defaultString(firstParamFromMap(params, "requestBody", "body", "request_body"), ""); + + @SuppressWarnings("unchecked") + Page pg = + (Page) new Query().getPage(params); + if (StringUtils.isBlank(DeviceImportUtils.firstParamString(params.get(Constant.ORDER_FIELD)))) { + pg.setOrders(Collections.singletonList(OrderItem.desc("create_time"))); + } + IPage page = baseMapper.selectJstCallLogPage( + pg, + mac != null ? mac : "", + beginTime, + endTime, + apiPathKw, + bodyKw); + return new PageUtils(page); + } + + private static Date resolveCreateTimeBegin(Map params) { + String raw = firstParamFromMap(params, "beginTime", "startTime", "beginCreatedAt", "startCreatedAt", + "start_time", "begin_time", "begin_created_at", "start_created_at"); + Date parsed = parseCreateTimeBoundary(raw, false); + ensureCreateTimeParsed( + "beginTime/startTime/beginCreatedAt/begin_created_at/start_time/begin_time", raw, parsed); + return parsed; + } + + private static Date resolveCreateTimeEnd(Map params) { + String raw = firstParamFromMap(params, "endTime", "endCreatedAt", "end_created_at", "end_time"); + Date parsed = parseCreateTimeBoundary(raw, true); + ensureCreateTimeParsed("endTime/endCreatedAt/end_created_at/end_time", raw, parsed); + return parsed; + } + + private static void ensureCreateTimeParsed(String paramName, String raw, Date parsed) { + if (StringUtils.isNotBlank(raw) && parsed == null) { + throw new ASException(paramName + " 格式错误,支持 yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss"); + } + } + + /** + * @param endOfDayIfDateOnly 仅 yyyy-MM-dd 时,结束边界扩展到当日 23:59:59.999 + */ + private static Date parseCreateTimeBoundary(String s, boolean endOfDayIfDateOnly) { + if (StringUtils.isBlank(s)) { + return null; + } + s = s.trim(); + try { + if (s.length() <= 10) { + Date d = DateUtils.stringToDate(s, DateUtils.DATE_PATTERN); + if (d != null && endOfDayIfDateOnly) { + return new DateTime(d).withTime(23, 59, 59, 999).toDate(); + } + return d; + } + return DateUtils.stringToDate(s, DateUtils.DATE_TIME_PATTERN); + } catch (RuntimeException ex) { + return null; + } + } + + private static String firstParamFromMap(Map params, String... keys) { + if (params == null || keys == null) { + return null; + } + for (String k : keys) { + String v = objectToTrimmedString(params.get(k)); + if (StringUtils.isNotBlank(v)) { + return v; + } + } + return null; + } + + private static String objectToTrimmedString(Object o) { + if (o == null) { + return null; + } + if (o instanceof String) { + return StringUtils.trimToNull((String) o); + } + return StringUtils.trimToNull(String.valueOf(o)); + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + public ApiJstCallLogEntity saveInNewTx(ApiJstCallLogEntity log) { + save(log); + return log; + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + public void updateInNewTx(ApiJstCallLogEntity log) { + updateById(log); + } +} diff --git a/src/main/java/io/as/modules/jst/service/impl/JushitingApiServiceImpl.java b/src/main/java/io/as/modules/jst/service/impl/JushitingApiServiceImpl.java new file mode 100755 index 0000000..2a61d5b --- /dev/null +++ b/src/main/java/io/as/modules/jst/service/impl/JushitingApiServiceImpl.java @@ -0,0 +1,391 @@ +package io.as.modules.jst.service.impl; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import io.as.common.utils.ConfigConstant; +import io.as.common.utils.RsaUtil; +import io.as.modules.jst.config.JstPlatformConfig; +import io.as.modules.jst.service.ApiJstCallLogService; +import io.as.modules.jst.service.JstActivateCardResult; +import io.as.modules.jst.service.JushitingApiService; +import io.as.modules.sys.service.SysConfigService; +import io.as.modules.jst.entity.ApiJstCallLogEntity; +import io.as.modules.device.entity.DeviceEntity; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.util.Date; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; + +/** + * 橘视厅免密登录、卡密激活、会员到期时间查询 + */ +@Service +public class JushitingApiServiceImpl implements JushitingApiService { + + private static final String SUCCESS_CODE = "00000000"; + /** 橘视厅卡密激活:与 E0000003 同码,retMsg 为「已激活,重复使用激活码」时视为幂等成功 */ + private static final String JST_ACTIVATE_MSG_ALREADY_ACTIVE = "已激活,重复使用激活码"; + + @Autowired + private RestTemplate restTemplate; + @Autowired + private SysConfigService sysConfigService; + @Autowired + private ApiJstCallLogService apiJstCallLogService; + + private JstPlatformConfig getConfig() { + return sysConfigService.getConfigObject(ConfigConstant.JST_PLATFORM_CONFIG, JstPlatformConfig.class); + } + + @Override + public Map getToken(DeviceEntity device, String thirdCardRef) { + JstPlatformConfig cfg = getConfig(); + String baseUrl = StringUtils.trimToEmpty(cfg.getJushitingBaseUrl()); + if (baseUrl.isEmpty()) { + return null; + } + + String timestamp = String.valueOf(System.currentTimeMillis()); + String nonce = UUID.randomUUID().toString().replace("-", ""); + String accesser = StringUtils.trimToEmpty(cfg.getJushitingAccesser()); + String businessLine = StringUtils.trimToEmpty(cfg.getJushitingBusinessLine()); + String serviceComboCode = StringUtils.trimToEmpty(cfg.getJushitingServiceComboCode()); + String macBul = thirdCardRef + "_bul_" + businessLine; + + TreeMap signParams = new TreeMap<>(); + signParams.put("accesser", accesser); + signParams.put("businessLine", businessLine); + signParams.put("uid", thirdCardRef); + signParams.put("mac", macBul); + signParams.put("nonce", nonce); + signParams.put("timestamp", timestamp); + signParams.put("serviceComboCode", serviceComboCode); + String plainText = JSON.toJSONString(signParams); + String signature = RsaUtil.encrypt(cfg.getJushitingLoginRsaPublicKey(), plainText); + + String url = baseUrl + "/ums_api/api/v1/third/login?" + + "accesser=" + accesser + + "&businessLine=" + businessLine + + "&mac=" + macBul + + "&nonce=" + nonce + + "&uid=" + thirdCardRef + + "×tamp=" + timestamp + + "&serviceComboCode=" + serviceComboCode; + + String requestBody = "{}"; + TreeMap reqHeaders = defaultTenantHeaders(cfg); + reqHeaders.put("encryptData", signature); + reqHeaders.put("Cookie", "locale=en-us"); + + long start = System.currentTimeMillis(); + ApiJstCallLogEntity log = createRequestLog(url, requestBody, device.getId(), JSON.toJSONString(reqHeaders)); + + Integer responseCode = null; + String responseBody = null; + String errorMsg = null; + + try { + HttpEntity entity = new HttpEntity<>(requestBody, toHttpHeaders(reqHeaders)); + ResponseEntity resp = restTemplate.postForEntity(url, entity, String.class); + responseCode = resp.getStatusCodeValue(); + responseBody = resp.getBody(); + + JSONObject json = JSON.parseObject(responseBody); + if (SUCCESS_CODE.equals(retCodeOf(json))) { + JSONObject retInfo = retInfoOf(json); + if (retInfo != null) { + Map out = new TreeMap<>(); + out.put("token", retInfo.getString("token")); + out.put("memberCode", retInfo.getString("memberCode")); + updateRequestLog(log, responseCode, responseBody, 1, start, null); + return out; + } + } + errorMsg = retMsgIfJson(json, responseBody); + updateRequestLog(log, responseCode, responseBody, 0, start, errorMsg); + return null; + } catch (Exception e) { + errorMsg = e.getMessage(); + updateRequestLog(log, responseCode, responseBody, 0, start, errorMsg); + throw new RuntimeException("橘视厅免密登录失败: " + errorMsg, e); + } + } + + @Override + public JstActivateCardResult activateCard(Long deviceId, String activateCode, String memberCode, String token) { + JstPlatformConfig cfg = getConfig(); + String baseUrl = StringUtils.trimToEmpty(cfg.getJushitingBaseUrl()); + if (baseUrl.isEmpty()) { + return JstActivateCardResult.failure(); + } + + long ts10 = System.currentTimeMillis() / 1000; + String serviceComboCode = StringUtils.trimToEmpty(cfg.getJushitingServiceComboCode()); + String key = StringUtils.trimToEmpty(cfg.getJushitingActivateSignKey()); + + TreeMap signParams = new TreeMap<>(); + signParams.put("activateCode", activateCode); + signParams.put("memberCode", memberCode); + signParams.put("timestamp", String.valueOf(ts10)); + String sign = md5SortedKeyValueThenSecret(signParams, key); + + String url = baseUrl + "/ccms_api/hswj/api/card/activate?sign=" + sign; + JSONObject body = new JSONObject(); + body.put("activateCode", activateCode); + body.put("memberCode", memberCode); + body.put("timestamp", ts10); + body.put("serviceComboCode", serviceComboCode); + String requestBody = body.toJSONString(); + + TreeMap reqHeaders = new TreeMap<>(); + reqHeaders.put("Content-Type", MediaType.APPLICATION_JSON_VALUE); + reqHeaders.put("tenant", cfg.getJushitingTenant()); + + long start = System.currentTimeMillis(); + ApiJstCallLogEntity log = createRequestLog(url, requestBody, deviceId, JSON.toJSONString(reqHeaders)); + + Integer responseCode = null; + String responseBody = null; + String errorMsg = null; + int success = 0; + String expireTimeFromInfo = null; + + try { + HttpEntity entity = new HttpEntity<>(requestBody, toHttpHeaders(reqHeaders)); + ResponseEntity resp = restTemplate.postForEntity(url, entity, String.class); + responseCode = resp.getStatusCodeValue(); + responseBody = resp.getBody(); + + JSONObject json = JSON.parseObject(responseBody); + String retCode = retCodeOf(json); + if (SUCCESS_CODE.equals(retCode)) { + JSONObject retInfo = retInfoOf(json); + Boolean status = retInfo != null ? retInfo.getBoolean("status") : null; + success = Boolean.TRUE.equals(status) ? 1 : 0; + if (success == 0) { + errorMsg = retMsgIfJson(json, "retInfo.status 非 true"); + } else if (retInfo != null) { + expireTimeFromInfo = StringUtils.trimToNull(retInfo.getString("expireTime")); + } + } else if ("E0000003".equals(retCode)) { + // E0000003 同码异义:卡密未售出=失败;已激活重复使用=幂等成功 + String retMsg = json != null ? json.getString("retMsg") : null; + if (StringUtils.isNotBlank(retMsg) && retMsg.contains(JST_ACTIVATE_MSG_ALREADY_ACTIVE)) { + success = 1; + JSONObject retInfo = retInfoOf(json); + if (retInfo != null) { + expireTimeFromInfo = StringUtils.trimToNull(retInfo.getString("expireTime")); + } + } else { + errorMsg = retMsgIfJson(json, responseBody); + } + } else { + errorMsg = retMsgIfJson(json, responseBody); + } + updateRequestLog(log, responseCode, responseBody, success, start, errorMsg); + if (success == 1) { + return JstActivateCardResult.success(expireTimeFromInfo); + } + return JstActivateCardResult.failure(); + } catch (Exception e) { + errorMsg = e.getMessage(); + updateRequestLog(log, responseCode, responseBody, 0, start, errorMsg); + throw new RuntimeException("橘视厅卡密激活失败: " + errorMsg, e); + } + } + + /** + * 查询会员到期时间。 + * 接口说明:①requestTime 不能与北京时间差距在 2 小时内,否则返回参数异常; + * ②请求头需包含 tenant;③sign=Md5(businessLine+requestTime+uid+key),key 固定为 c25svtwbTyCE; + * ④请求方式 x-www-form-urlencoded。 + */ + @Override + public String queryVipExpireTime(Long deviceId, String uid) { + JstPlatformConfig cfg = getConfig(); + String baseUrl = StringUtils.trimToEmpty(cfg.getVipQueryBaseUrl()); + if (baseUrl.isEmpty()) { + return null; + } + + String requestTime = String.valueOf(System.currentTimeMillis()); + String businessLine = StringUtils.trimToEmpty(cfg.getJushitingBusinessLine()); + // sign=Md5(businessLine + requestTime + uid + key),key 固定 + String key = "c25svtwbTyCE"; + String sign = DigestUtils.md5Hex(businessLine + requestTime + uid + key); + + String url = baseUrl + "/ums_api/member/uid/queryVipInfo"; + + TreeMap reqHeaders = new TreeMap<>(); + reqHeaders.put("Content-Type", MediaType.APPLICATION_FORM_URLENCODED_VALUE); + reqHeaders.put("tenant", cfg.getJushitingTenant()); + + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("requestTime", requestTime); + form.add("uid", uid); + form.add("businessLine", businessLine); + form.add("sign", sign); + + long start = System.currentTimeMillis(); + ApiJstCallLogEntity log = createRequestLog(url, form.toString(), deviceId, JSON.toJSONString(reqHeaders)); + + Integer responseCode = null; + String responseBody = null; + String errorMsg = null; + int success = 0; + String vipExpireTime = null; + + try { + HttpHeaders headers = toHttpHeaders(reqHeaders); + HttpEntity> entity = new HttpEntity<>(form, headers); + ResponseEntity resp = restTemplate.postForEntity(url, entity, String.class); + responseCode = resp.getStatusCodeValue(); + responseBody = resp.getBody(); + + JSONObject json = JSON.parseObject(responseBody); + if (SUCCESS_CODE.equals(retCodeOf(json))) { + JSONObject retInfo = retInfoOf(json); + vipExpireTime = retInfo != null ? retInfo.getString("vipExpireTime") : null; + success = 1; + } else { + errorMsg = retMsgIfJson(json, responseBody); + } + updateRequestLog(log, responseCode, responseBody, success, start, errorMsg); + return vipExpireTime; + } catch (Exception e) { + errorMsg = e.getMessage(); + updateRequestLog(log, responseCode, responseBody, 0, start, errorMsg); + throw new RuntimeException("会员到期时间查询失败: " + errorMsg, e); + } + } + + private static TreeMap defaultTenantHeaders(JstPlatformConfig cfg) { + TreeMap h = new TreeMap<>(); + h.put("tenant", cfg.getJushitingTenant()); + return h; + } + + private static String retCodeOf(JSONObject json) { + return json != null ? json.getString("retCode") : null; + } + + /** json 为 null 时用 fallback;否则取 retMsg(可为 null,与原先三元表达式一致) */ + private static String retMsgIfJson(JSONObject json, String whenJsonNull) { + if (json == null) { + return whenJsonNull; + } + return json.getString("retMsg"); + } + + private static JSONObject retInfoOf(JSONObject json) { + return json != null ? json.getJSONObject("retInfo") : null; + } + + /** + * 非空参数按 key 字典序 key=value&... 拼接,末尾追加 secret,MD5 大写(卡密激活 sign) + */ + private static String md5SortedKeyValueThenSecret(TreeMap signParams, String secretKey) { + StringBuilder signSb = new StringBuilder(); + for (Map.Entry e : signParams.entrySet()) { + if (e.getValue() == null) { + continue; + } + if (signSb.length() > 0) { + signSb.append("&"); + } + signSb.append(e.getKey()).append("=").append(e.getValue()); + } + signSb.append("&key=").append(secretKey); + return DigestUtils.md5Hex(signSb.toString()).toUpperCase(); + } + + /** 请求发出前创建并保存日志,返回日志实体供后续更新(含请求信息、请求头 JSON,失败时仍返回以支持 fallback 插入) */ + private ApiJstCallLogEntity createRequestLog(String url, String requestBody, Long deviceId, String requestHeadersJson) { + ApiJstCallLogEntity log = new ApiJstCallLogEntity(); + // api_path:完整请求 URL,与表 api_jst_call_log.api_path varchar(2000) 对齐 + log.setApiPath(truncateStrict(StringUtils.trimToEmpty(url), 2000)); + log.setRequestMethod("POST"); + log.setRequestBody(requestBody != null ? requestBody : ""); + log.setDataId(deviceId); + log.setRequestHeaders(truncateStrict(requestHeadersJson, 2000)); // varchar(2000) + log.setSuccess(0); // NOT NULL,初始为待处理 + log.setCreateTime(new Date()); + try { + log = apiJstCallLogService.saveInNewTx(log); + } catch (Exception e) { + // 保存失败时保留 log(含请求信息),updateRequestLog 会做 fallback 完整插入 + } + return log; + } + + /** 请求完成后更新日志;若预插入失败(log.id 为空)则做完整插入 */ + private void updateRequestLog(ApiJstCallLogEntity log, Integer responseCode, String responseBody, + int success, long startTime, String errorMsg) { + if (log == null) { + return; + } + try { + int duration = (int) (System.currentTimeMillis() - startTime); + log.setResponseCode(responseCode); + log.setResponseBody(truncate(responseBody, 8000)); + log.setSuccess(success); + log.setDuration(duration); + log.setErrorMessage(truncate(errorMsg, 500)); + if (log.getId() != null) { + apiJstCallLogService.updateInNewTx(log); + } else { + // 预插入失败时的 fallback:完整插入(请求+响应) + apiJstCallLogService.saveInNewTx(log); + } + } catch (Exception e) { + // 日志更新失败不影响主流程 + } + } + + private static String truncate(String s, int maxLen) { + if (s == null) { + return null; + } + if (s.length() <= maxLen) { + return s; + } + return s.substring(0, maxLen) + "..."; + } + + /** 严格截断,不追加省略号,用于有长度限制的数据库列(如 api_path、request_headers 均为 varchar(2000)) */ + private static String truncateStrict(String s, int maxLen) { + if (s == null) { + return null; + } + if (s.length() <= maxLen) { + return s; + } + return s.substring(0, maxLen); + } + + /** 将 reqHeaders 转为 HttpHeaders,确保记录的请求头与实际发送一致;value 为 null 时发送空字符串 */ + private static HttpHeaders toHttpHeaders(Map reqHeaders) { + HttpHeaders headers = new HttpHeaders(); + if (reqHeaders != null) { + for (Map.Entry e : reqHeaders.entrySet()) { + if (e.getKey() != null) { + headers.set(e.getKey(), e.getValue() != null ? e.getValue() : ""); + } + } + } + return headers; + } + +} diff --git a/src/main/java/io/as/modules/media/client/JetsenMediaClient.java b/src/main/java/io/as/modules/media/client/JetsenMediaClient.java new file mode 100644 index 0000000..beba651 --- /dev/null +++ b/src/main/java/io/as/modules/media/client/JetsenMediaClient.java @@ -0,0 +1,183 @@ +package io.as.modules.media.client; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import io.as.modules.media.config.JetsenMediaConfig; +import org.apache.commons.codec.digest.DigestUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 橘视厅媒资 HTTP 客户端 + * + * 复刻 Android 端 SignUtil.kt + ApiService.kt 的调用方式: + * - 公共参数:timeshift, sign, portalcode, sptoken, channelid + * - 签名:MD5(sptoken + timeshift + portalcode + signKey).toUpperCase() + * - 请求:POST application/json + */ +@Component +public class JetsenMediaClient { + + private static final Logger logger = LoggerFactory.getLogger(JetsenMediaClient.class); + + @Autowired + private JetsenMediaConfig config; + + @Autowired + private RestTemplate restTemplate; + + /** 构建公共参数(含签名) */ + private Map buildCommonParams() { + String timeshift = String.valueOf(System.currentTimeMillis()); + String sign = DigestUtils.md5Hex(config.getSpToken() + timeshift + config.getPortalCode() + config.getSignKey()).toUpperCase(); + Map params = new HashMap<>(); + params.put("timeshift", timeshift); + params.put("sign", sign); + params.put("portalcode", config.getPortalCode()); + params.put("sptoken", config.getSpToken()); + params.put("channelid", config.getChannelId()); + return params; + } + + /** 构建完整请求参数(公共参数 + 业务参数) */ + private Map buildParams(Map business) { + Map params = buildCommonParams(); + if (business != null) { + params.putAll(business); + } + return params; + } + + /** 统一 POST JSON 请求 */ + private JSONObject post(String path, Map business) { + String url = config.getBaseUrl() + config.getPathPrefix() + "/" + path; + Map body = buildParams(business); + String jsonBody = JSON.toJSONString(body); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(jsonBody, headers); + + logger.info("橘视厅媒资请求: url={} body={}", url, jsonBody); + try { + ResponseEntity resp = restTemplate.postForEntity(url, entity, String.class); + String respBody = resp.getBody(); + logger.info("橘视厅媒资响应: url={} status={} body={}", url, resp.getStatusCodeValue(), truncate(respBody, 2000)); + return JSON.parseObject(respBody); + } catch (Exception e) { + logger.error("橘视厅媒资请求失败: url={}", url, e); + throw new RuntimeException("调用橘视厅媒资接口失败: " + e.getMessage(), e); + } + } + + /** + * 内容列表(按栏目) + * @param catalogid 栏目 id(必填) + * @param pageindex 页码,从 1 开始 + * @param pagesize 每页条数 + */ + public JSONObject getContentList(String catalogid, int pageindex, int pagesize) { + Map biz = new HashMap<>(); + biz.put("catalogid", catalogid); + biz.put("orderinfo", "1"); + biz.put("pageindex", String.valueOf(pageindex)); + biz.put("pagesize", String.valueOf(pagesize)); + return post("content/getcontentlist.do", biz); + } + + /** + * 搜索 + * @param searchvalue 关键字 + * @param pageindex 页码 + * @param pagesize 每页条数 + */ + public JSONObject search(String searchvalue, int pageindex, int pagesize) { + Map biz = new HashMap<>(); + biz.put("searchvalue", searchvalue); + biz.put("pageindex", String.valueOf(pageindex)); + biz.put("pagesize", String.valueOf(pagesize)); + return post("search/getsearchlist.do", biz); + } + + /** + * 栏目标签 + * @param parentid 父栏目 id(一级栏目传 "0") + */ + public JSONObject getTagsList(String parentid) { + Map biz = new HashMap<>(); + biz.put("parentid", parentid); + return post("content/gettagslist.do", biz); + } + + /** + * 栏目树(一级栏目 + 其下二级子栏目) + * 一级栏目(如471电影/473电视剧)是分类容器不含内容,必须用二级子栏目 id(如472最新上映)查内容列表。 + * 此方法一次返回完整两级栏目树,供前端分组下拉/级联选择。 + * @return JSONArray,每项含 id/name/code/children(children 为 JSONArray,可能为空) + */ + public JSONArray getCatalogTree() { + JSONArray tree = new JSONArray(); + List roots = extractList(getTagsList("0")); + for (JSONObject root : roots) { + JSONObject node = new JSONObject(true); + node.put("id", root.get("id")); + node.put("name", root.get("name")); + node.put("code", root.get("code")); + JSONArray children = new JSONArray(); + for (JSONObject sub : extractList(getTagsList(String.valueOf(root.get("id"))))) { + JSONObject child = new JSONObject(true); + child.put("id", sub.get("id")); + child.put("name", sub.get("name")); + child.put("code", sub.get("code")); + children.add(child); + } + node.put("children", children); + tree.add(node); + } + return tree; + } + + /** 从橘视厅响应 result 字段提取为 List */ + private List extractList(JSONObject resp) { + List list = new ArrayList<>(); + if (resp == null) { + return list; + } + Object result = resp.get("result"); + if (result instanceof JSONArray) { + for (Object o : (JSONArray) result) { + if (o instanceof JSONObject) { + list.add((JSONObject) o); + } + } + } + return list; + } + + /** + * 首页导航(一级栏目入口) + */ + public JSONObject getLayoutNav() { + return post("layout/cornerLayoutNav.do", new HashMap<>()); + } + + private static String truncate(String s, int maxLen) { + if (s == null) { + return null; + } + return s.length() <= maxLen ? s : s.substring(0, maxLen) + "..."; + } +} diff --git a/src/main/java/io/as/modules/media/config/JetsenMediaConfig.java b/src/main/java/io/as/modules/media/config/JetsenMediaConfig.java new file mode 100644 index 0000000..aab27f6 --- /dev/null +++ b/src/main/java/io/as/modules/media/config/JetsenMediaConfig.java @@ -0,0 +1,41 @@ +package io.as.modules.media.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 橘视厅媒资接口配置(对应 Android 端 ApiConfig.kt) + * + * 签名算法:sign = MD5(sptoken + timeshift + portalcode + signKey).toUpperCase() + * 请求方式:POST application/json(与 Android 端 Retrofit @Body Map + GsonConverterFactory 一致) + */ +@Data +@Component +@ConfigurationProperties(prefix = "jetsen.media") +public class JetsenMediaConfig { + + /** 媒资接口基础地址 */ + private String baseUrl = "https://servhub-ep03.future.jetsentv.com/"; + + /** 接口路径前缀 */ + private String pathPrefix = "/IPTVPortalInterface"; + + /** 固定凭证 - sptoken */ + private String spToken = "9006874392099a89506142c6df9e3314"; + + /** 固定凭证 - portalcode */ + private String portalCode = "PCODE-JC"; + + /** 签名密钥 */ + private String signKey = "alltvjs"; + + /** 渠道号 */ + private String channelId = "001"; + + /** 导出 Excel 时单次拉取页大小 */ + private int exportPageSize = 50; + + /** 导出 Excel 时最大拉取条数(防止超量) */ + private int exportMaxRows = 5000; +} diff --git a/src/main/java/io/as/modules/media/controller/JetsenMediaController.java b/src/main/java/io/as/modules/media/controller/JetsenMediaController.java new file mode 100644 index 0000000..4108580 --- /dev/null +++ b/src/main/java/io/as/modules/media/controller/JetsenMediaController.java @@ -0,0 +1,232 @@ +package io.as.modules.media.controller; + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import io.as.common.utils.R; +import io.as.modules.media.client.JetsenMediaClient; +import io.as.modules.media.config.JetsenMediaConfig; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletResponse; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 橘视厅媒资 Controller(管理后台用,走 JWT 鉴权) + * + * 数据来源:橘视厅 IPTVPortalInterface HTTP 接口(复刻 Android 端调用) + * - 内容列表:content/getcontentlist.do + * - 搜索: search/getsearchlist.do + * - 栏目标签: content/gettagslist.do + * - 首页导航: layout/cornerLayoutNav.do + */ +@Api(tags = "橘视厅媒资") +@RestController +@RequestMapping("/manage/jetsen-media") +public class JetsenMediaController { + + private static final Logger logger = LoggerFactory.getLogger(JetsenMediaController.class); + + @Autowired + private JetsenMediaClient client; + + @Autowired + private JetsenMediaConfig config; + + /** 首页导航(一级栏目入口,用于列表页栏目选择) */ + @ApiOperation("首页导航") + @GetMapping("/nav") + public R nav() { + JSONObject resp = client.getLayoutNav(); + return R.dataSuccess(toData(resp)); + } + + /** 栏目标签(按 parentid 查子栏目,一级传 0) */ + @ApiOperation("栏目标签") + @GetMapping("/tags") + public R tags(@RequestParam(defaultValue = "0") String parentid) { + JSONObject resp = client.getTagsList(parentid); + return R.dataSuccess(toData(resp)); + } + + /** + * 栏目树(一级栏目 + 其下二级子栏目) + * 一级栏目(如471电影)是分类容器不含内容,查内容列表必须用二级子栏目 id(如472最新上映=3221条)。 + * 前端用此接口渲染分组下拉/级联选择器,选中二级栏目后用其 id 调 /list。 + */ + @ApiOperation("栏目树") + @GetMapping("/catalog-tree") + public R catalogTree() { + JSONArray tree = client.getCatalogTree(); + Map data = new HashMap<>(); + data.put("list", tree); + return R.dataSuccess(data); + } + + /** 内容列表(按栏目 catalogid 分页) */ + @ApiOperation("内容列表") + @GetMapping("/list") + public R list(@RequestParam String catalogid, + @RequestParam(defaultValue = "1") int pageindex, + @RequestParam(defaultValue = "20") int pagesize) { + JSONObject resp = client.getContentList(catalogid, pageindex, pagesize); + return R.dataSuccess(toPageData(resp)); + } + + /** 搜索 */ + @ApiOperation("搜索") + @GetMapping("/search") + public R search(@RequestParam String keyword, + @RequestParam(defaultValue = "1") int pageindex, + @RequestParam(defaultValue = "20") int pagesize) { + JSONObject resp = client.search(keyword, pageindex, pagesize); + return R.dataSuccess(toPageData(resp)); + } + + /** + * 导出 Excel + * @param catalogid 栏目 id(与 keyword 二选一) + * @param keyword 搜索关键字(与 catalogid 二选一) + */ + @ApiOperation("导出Excel") + @GetMapping("/export") + public void export(@RequestParam(required = false) String catalogid, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String catalogName, + HttpServletResponse response) throws Exception { + List all = new ArrayList<>(); + int pageindex = 1; + int pagesize = config.getExportPageSize(); + int maxRows = config.getExportMaxRows(); + boolean bySearch = keyword != null && !keyword.trim().isEmpty(); + + while (all.size() < maxRows) { + JSONObject resp = bySearch + ? client.search(keyword.trim(), pageindex, pagesize) + : client.getContentList(catalogid, pageindex, pagesize); + List page = extractResultList(resp); + if (page.isEmpty()) { + break; + } + all.addAll(page); + // 达到最后一页则停止 + Integer totalPage = resp.getInteger("pagetotal"); + if (totalPage == null || pageindex >= totalPage) { + break; + } + pageindex++; + } + if (all.size() > maxRows) { + all = all.subList(0, maxRows); + } + + String hint = bySearch ? ("搜索_" + keyword) : ("栏目_" + (catalogName != null && !catalogName.isEmpty() ? catalogName : catalogid)); + writeExcel(all, response, hint); + } + + /** 把橘视厅响应统一包装为分页数据 */ + private Map toPageData(JSONObject resp) { + Map data = new HashMap<>(); + data.put("list", extractResultList(resp)); + data.put("totalCount", resp.getIntValue("sizetotal")); + data.put("currPage", resp.getIntValue("pageindex")); + data.put("pageSize", resp.getIntValue("pagesize")); + data.put("totalPage", resp.getIntValue("pagetotal")); + return data; + } + + /** 把橘视厅响应统一包装为普通数据(list / 原始字段透传) */ + private Map toData(JSONObject resp) { + Map data = new HashMap<>(); + Object result = resp.get("result"); + if (result == null) { + // 导航/布局等结构可能放在 data 字段 + result = resp.get("data"); + } + data.put("list", result != null ? result : new ArrayList<>()); + return data; + } + + /** 从响应中提取 result 列表(兼容 JSONObject/JSONArray) */ + @SuppressWarnings("unchecked") + private List extractResultList(JSONObject resp) { + List list = new ArrayList<>(); + if (resp == null) { + return list; + } + Object result = resp.get("result"); + if (result instanceof JSONArray) { + for (Object o : (JSONArray) result) { + if (o instanceof JSONObject) { + list.add((JSONObject) o); + } + } + } + return list; + } + + /** 生成并输出 xlsx */ + private void writeExcel(List rows, HttpServletResponse response, String nameHint) throws Exception { + String[] headers = {"内容编码", "名称", "类型", "评分", "演员", "简介", "年份", "地区", "是否免费", "集数", "时长", "海报图"}; + String[] keys = {"contentcode", "name", "categoryname", "score", "actordisplay", "description", "releaseyear", "originalcountry", "isFree", "episodecount", "duration", "posterimg"}; + + Workbook wb = new XSSFWorkbook(); + Sheet sheet = wb.createSheet("媒资列表"); + // 表头样式 + CellStyle headStyle = wb.createCellStyle(); + Font font = wb.createFont(); + font.setBold(true); + headStyle.setFont(font); + headStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + headStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); + + Row head = sheet.createRow(0); + for (int i = 0; i < headers.length; i++) { + Cell c = head.createCell(i); + c.setCellValue(headers[i]); + c.setCellStyle(headStyle); + } + for (int r = 0; r < rows.size(); r++) { + JSONObject item = rows.get(r); + Row row = sheet.createRow(r + 1); + for (int i = 0; i < keys.length; i++) { + Cell c = row.createCell(i); + Object v = item.get(keys[i]); + if (v == null) { + c.setCellValue(""); + } else if ("isFree".equals(keys[i])) { + c.setCellValue("1".equals(String.valueOf(v)) ? "免费" : "付费"); + } else { + c.setCellValue(String.valueOf(v)); + } + } + } + for (int i = 0; i < headers.length; i++) { + sheet.autoSizeColumn(i); + } + + String fileName = URLEncoder.encode("橘视厅媒资_" + nameHint + "_" + rows.size() + "条.xlsx", "UTF-8") + .replaceAll("\\+", "%20"); + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + fileName); + OutputStream out = response.getOutputStream(); + wb.write(out); + out.flush(); + wb.close(); + logger.info("媒资Excel导出完成: 共{}条", rows.size()); + } +} diff --git a/src/main/java/io/as/modules/media/controller/MediaController.java b/src/main/java/io/as/modules/media/controller/MediaController.java new file mode 100644 index 0000000..9d12e79 --- /dev/null +++ b/src/main/java/io/as/modules/media/controller/MediaController.java @@ -0,0 +1,52 @@ +package io.as.modules.media.controller; + +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.modules.media.service.MediaService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 媒资 Controller(管理后台用) + *

App 端播放鉴权已迁移至 POST /api/jst/checkBenefits(ApiJstController)

+ */ +@Api(tags = "媒资") +@RestController +@RequestMapping("/openapi/media") +public class MediaController { + + @Autowired + private MediaService mediaService; + + /** + * 媒资列表 + */ + @ApiOperation("媒资列表") + @GetMapping("/list") + public R list(@RequestParam(required = false) Map params) { + PageUtils page = mediaService.queryPage(params); + return R.dataSuccess(new HashMap(){{ + put("list", page.getList()); + put("totalCount", page.getTotalCount()); + put("currPage", page.getCurrPage()); + put("pageSize", page.getPageSize()); + put("totalPage", page.getTotalPage()); + }}); + } + + /** + * 媒资详情 + */ + @ApiOperation("媒资详情") + @GetMapping("/info/{mediaId}") + public R info(@ApiParam(value = "媒资ID", required = true) @PathVariable("mediaId") String mediaId) { + Map info = mediaService.getMediaInfo(mediaId); + return R.dataSuccess(info); + } +} diff --git a/src/main/java/io/as/modules/media/service/MediaService.java b/src/main/java/io/as/modules/media/service/MediaService.java new file mode 100644 index 0000000..efac826 --- /dev/null +++ b/src/main/java/io/as/modules/media/service/MediaService.java @@ -0,0 +1,35 @@ +package io.as.modules.media.service; + +import io.as.common.utils.PageUtils; + +import java.util.Map; + +/** + * 媒资 Service + */ +public interface MediaService { + + /** + * 媒资分页查询 + * + * @param params 查询参数(page, limit, keyword 等) + * @return 分页结果 + */ + PageUtils queryPage(Map params); + + /** + * 获取媒资详情 + * + * @param mediaId 媒资ID + * @return 媒资详情信息 + */ + Map getMediaInfo(String mediaId); + + /** + * 检查设备播放权限(校验设备是否有有效天卡权益) + * + * @param deviceId 设备ID + * @return true=有播放权限,false=无播放权限 + */ + boolean checkPlayPermission(Long deviceId); +} diff --git a/src/main/java/io/as/modules/media/service/impl/MediaServiceImpl.java b/src/main/java/io/as/modules/media/service/impl/MediaServiceImpl.java new file mode 100644 index 0000000..da373f8 --- /dev/null +++ b/src/main/java/io/as/modules/media/service/impl/MediaServiceImpl.java @@ -0,0 +1,99 @@ +package io.as.modules.media.service.impl; + +import io.as.common.utils.PageUtils; +import io.as.modules.media.service.MediaService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 媒资 Service 实现 + * + * 媒资数据来源待定:可对接橘视厅媒资 API 或自建媒资目录。 + */ +@Service("mediaService") +public class MediaServiceImpl implements MediaService { + + private static final Logger logger = LoggerFactory.getLogger(MediaServiceImpl.class); + + /** 天卡订单状态-已支付 */ + private static final String ORDER_STATUS_PAID = "PAID"; + + @org.springframework.beans.factory.annotation.Autowired + private JdbcTemplate jdbcTemplate; + + /** + * 媒资分页查询 + * + * TODO: 对接橘视厅媒资 API 或自建媒资目录表,当前返回空列表骨架 + */ + @Override + public PageUtils queryPage(Map params) { + // TODO: 对接橘视厅媒资 API 获取媒资列表 + // 当前为骨架实现,返回空列表 + logger.info("媒资分页查询: params={}", params); + + int page = 1; + int limit = 10; + if (params.get("page") != null) { + page = Integer.parseInt(String.valueOf(params.get("page"))); + } + if (params.get("limit") != null) { + limit = Integer.parseInt(String.valueOf(params.get("limit"))); + } + + // TODO: 替换为实际媒资数据查询 + List list = new ArrayList<>(); + int totalCount = 0; + + return new PageUtils(list, totalCount, limit, page); + } + + /** + * 获取媒资详情 + * + * TODO: 对接橘视厅媒资 API 获取媒资详情 + */ + @Override + public Map getMediaInfo(String mediaId) { + // TODO: 对接橘视厅媒资 API 获取媒资详情 + logger.info("获取媒资详情: mediaId={}", mediaId); + + Map info = new HashMap<>(); + info.put("mediaId", mediaId); + // TODO: 填充媒资标题、封面、播放地址、时长等字段 + return info; + } + + /** + * 检查设备播放权限 + * + * 查询 day_card_pool 表,检查设备是否有 USED 状态且未过期的天卡。 + * 判断条件:存在 day_card_pool 记录,status = 'USED',且 expire_at > 当前时间。 + */ + @Override + public boolean checkPlayPermission(Long deviceId) { + if (deviceId == null) { + return false; + } + + try { + // 查询设备是否有 USED 状态且未过期的天卡(expire_at 为卡有效期,非订单超时时间) + String sql = "SELECT COUNT(1) FROM day_card_pool WHERE device_id = ? AND status = 'USED' AND expire_at > NOW() AND delete_flag = 'UNDEL'"; + Integer count = jdbcTemplate.queryForObject(sql, Integer.class, deviceId); + + boolean hasPermission = count != null && count > 0; + logger.info("设备播放权限检查: deviceId={}, hasPermission={}", deviceId, hasPermission); + return hasPermission; + } catch (Exception e) { + logger.error("检查设备播放权限异常: deviceId={}", deviceId, e); + return false; + } + } +} diff --git a/src/main/java/io/as/modules/openapi/config/ApiConfig.java b/src/main/java/io/as/modules/openapi/config/ApiConfig.java new file mode 100755 index 0000000..631d1fe --- /dev/null +++ b/src/main/java/io/as/modules/openapi/config/ApiConfig.java @@ -0,0 +1,46 @@ +package io.as.modules.openapi.config; + +import io.as.common.utils.RedisUtils; +import io.as.modules.openapi.filter.ApiDeviceSignFilter; +import io.as.modules.openapi.filter.PartnerSignFilter; +import io.as.modules.openapi.sign.ApiDeviceSignService; +import io.as.modules.openapi.service.OpenApiPartnerService; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * API 模块配置:注册设备 sign 校验过滤器 + 合作伙伴签名校验过滤器 + *

对齐生产环境 as-partner-backend 的 ApiConfig

+ */ +@Configuration +public class ApiConfig { + + /** + * 设备 sign 校验过滤器:/api 下除 POST /api/jst/init、/api/payment/** 外均需请求头 sign,否则拒绝 + */ + @Bean + public FilterRegistrationBean apiDeviceSignFilterRegistration(ApiDeviceSignService apiDeviceSignService) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(new ApiDeviceSignFilter(apiDeviceSignService)); + registration.addUrlPatterns("/api/*"); + registration.setOrder(Integer.MAX_VALUE - 100); + registration.setName("apiDeviceSignFilter"); + return registration; + } + + /** + * 合作伙伴签名校验过滤器:/openapi/partner/** 需请求头 appKey/timestamp/nonce/sign + *

签名算法:MD5(appKey + timestamp + nonce + appSecret).toUpperCase()

+ */ + @Bean + public FilterRegistrationBean partnerSignFilterRegistration( + OpenApiPartnerService openApiPartnerService, RedisUtils redisUtils) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(new PartnerSignFilter(openApiPartnerService, redisUtils)); + registration.addUrlPatterns("/openapi/partner/*"); + registration.setOrder(Integer.MAX_VALUE - 100); + registration.setName("partnerSignFilter"); + return registration; + } +} diff --git a/src/main/java/io/as/modules/openapi/config/OpenApiConfig.java b/src/main/java/io/as/modules/openapi/config/OpenApiConfig.java new file mode 100644 index 0000000..b9ba716 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/config/OpenApiConfig.java @@ -0,0 +1,48 @@ +package io.as.modules.openapi.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.web.client.RestTemplate; + +import java.util.concurrent.ThreadPoolExecutor; + +/** + * OpenAPI 配置类 + *

设备 sign 校验过滤器已在 {@link ApiConfig} 中注册

+ *

注:RestTemplate 与 thirdPartyActivateExecutor 已分别在 ApiConfig、AsyncConfig 中注册, + * 此处使用 @ConditionalOnMissingBean 确保不重复注册,若上述 Bean 已存在则自动跳过。

+ */ +@Configuration +public class OpenApiConfig { + + /** + * RestTemplate:连接超时10s,读取超时30s + */ + @Bean + @ConditionalOnMissingBean(RestTemplate.class) + public RestTemplate openApiRestTemplate(RestTemplateBuilder builder) { + return builder.setConnectTimeout(java.time.Duration.ofSeconds(10)) + .setReadTimeout(java.time.Duration.ofSeconds(30)) + .build(); + } + + /** + * 第三方激活专用线程池:核心2,最大8,队列100,CallerRunsPolicy + */ + @Bean("thirdPartyActivateExecutor") + @ConditionalOnMissingBean(name = "thirdPartyActivateExecutor") + public TaskExecutor openApiThirdPartyActivateExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("thirdparty-activate-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/io/as/modules/openapi/constants/OpenApiConstants.java b/src/main/java/io/as/modules/openapi/constants/OpenApiConstants.java new file mode 100644 index 0000000..0dab80a --- /dev/null +++ b/src/main/java/io/as/modules/openapi/constants/OpenApiConstants.java @@ -0,0 +1,16 @@ +package io.as.modules.openapi.constants; + +/** + * 开放API常量 + *

注:旧的 PartnerSign 四头签名机制已废弃,相关常量已移除。设备 sign 鉴权使用单 sign 头,详见 ApiDeviceSignService。

+ */ +public final class OpenApiConstants { + + private OpenApiConstants() { + } + + /** 状态:启用 */ + public static final String STATUS_ACTIVE = "ACTIVE"; + /** 状态:禁用 */ + public static final String STATUS_DISABLED = "DISABLED"; +} diff --git a/src/main/java/io/as/modules/openapi/controller/ApiJstController.java b/src/main/java/io/as/modules/openapi/controller/ApiJstController.java new file mode 100644 index 0000000..d12af24 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/controller/ApiJstController.java @@ -0,0 +1,418 @@ +package io.as.modules.openapi.controller; + +import io.as.common.annotation.AuditAction; +import io.as.common.exception.ASException; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.entity.DayCardActivationCodeEntity; +import io.as.modules.daycard.entity.DayCardOrderEntity; +import io.as.modules.daycard.entity.DayCardPoolEntity; +import io.as.modules.daycard.entity.DayCardPriceConfigEntity; +import io.as.modules.daycard.service.DayCardActivationCodeService; +import io.as.modules.daycard.service.DayCardOrderService; +import io.as.modules.device.entity.DeviceEntity; +import io.as.modules.device.service.DeviceService; +import io.as.modules.openapi.filter.ApiDeviceSignFilter; +import io.as.modules.openapi.form.DeviceInitRequest; +import io.as.modules.openapi.sign.ApiDeviceSignService; +import io.as.modules.openapi.sign.DeviceSignResult; +import io.as.modules.sys.entity.SysOrgEntity; +import io.as.modules.sys.service.SysOrgService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiImplicitParam; +import io.swagger.annotations.ApiImplicitParams; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * 橘视听 API 设备接口(对齐生产环境 as-partner-backend 的 ApiJstController) + *

完整流程:

+ *
    + *
  1. init:设备初始化(注册/更新),返回 sign
  2. + *
  3. checkBenefits:查询天卡权益,返回 thirdCardNo 与 status(ACTIVE/UNBOUND)
  4. + *
  5. 无权益 → daycard/create 创建天卡订单 → 支付 → 轮询 checkBenefits 拿 thirdCardNo
  6. + *
+ *

鉴权:除 POST /api/jst/init 外,均须在请求头携带 sign。缺少或无效 sign 由过滤器直接返回 HTTP 401。

+ */ +@Api(tags = "橘视听-API-设备接口", + description = "除 POST /api/jst/init 外均需请求头 sign;缺/无效 sign→401。业务结果以 JSON 的 code 为准。") +@RestController +@RequestMapping("/api/jst") +public class ApiJstController { + + private static final Logger logger = LoggerFactory.getLogger(ApiJstController.class); + + @Autowired + private ApiDeviceSignService apiDeviceSignService; + + @Autowired + private DeviceService deviceService; + + @Autowired + private SysOrgService sysOrgService; + + @Autowired + private DayCardOrderService dayCardOrderService; + + @Autowired + private DayCardActivationCodeService dayCardActivationCodeService; + + /** + * 设备初始化:校验设备是否在系统,不在则添加、在则更新,返回 sign + */ + @ApiOperation(value = "设备初始化", + notes = "本接口无需 sign。body 必传 activationCode(管理后台按门店生成的激活码);设备可传 mac、wifiMac、deviceSn、deviceUid 等。成功返回 data.device、sign。") + @AuditAction(value = "DEVICE_INIT", description = "设备初始化") + @PostMapping("/init") + public Map init( + @ApiParam(value = "设备初始化请求体", required = true) + @RequestBody DeviceInitRequest request) { + try { + return doInit(request); + } catch (ASException e) { + return rrExceptionResponse(e); + } + } + + /** + * 设备权益状态:查询天卡权益,返回 thirdCardNo + status + *

status: ACTIVE(橘视厅激活成功且未过期,返回 thirdCardNo)/ ACTIVATING(激活中)/ ACTIVATION_FAILED(激活失败)/ UNBOUND(无天卡)

+ */ + @ApiOperation(value = "设备权益状态", + notes = "请求头必传 sign。查询设备天卡权益,返回 data.status(ACTIVE/ACTIVATING/ACTIVATION_FAILED/UNBOUND)。仅 ACTIVE 时返回 data.thirdCardNo(播放凭证)和 data.expireAt。") + @ApiImplicitParams({ + @ApiImplicitParam(name = "sign", value = "设备签名(用于标识设备)", required = true, paramType = "header", dataType = "string") + }) + @PostMapping("/checkBenefits") + public Map checkBenefits(HttpServletRequest request) { + Long deviceId = deviceIdFrom(request); + DayCardPoolEntity pool = dayCardOrderService.getDeviceBenefits(deviceId); + Map data = new HashMap<>(); + if (pool == null) { + data.put("status", "UNBOUND"); + } else if (DayCardConstants.ACTIVATE_STATUS_SUCCESS.equals(pool.getActivateStatus())) { + data.put("status", "ACTIVE"); + data.put("thirdCardNo", pool.getThirdCardNo()); + data.put("cardNo", pool.getCardCode()); + data.put("expireAt", pool.getExpireAt()); + } else if (DayCardConstants.ACTIVATE_STATUS_FAILED.equals(pool.getActivateStatus())) { + data.put("status", "ACTIVATION_FAILED"); + data.put("activateError", pool.getActivateError()); + } else { + // PENDING 或 null,激活中 + data.put("status", "ACTIVATING"); + } + return buildSuccessResponse(data); + } + + /** + * 设备天卡价格:供前端二维码展示 + *

按 3 层维度匹配当前设备生效价:门店配置价 > 渠道配置价 > 平台默认价

+ */ + @ApiOperation(value = "设备天卡价格", + notes = "请求头必传 sign。返回 data.price(元)、data.priceFen(分)、data.name。未配置定价时 data.status=UNCONFIGURED。") + @ApiImplicitParams({ + @ApiImplicitParam(name = "sign", value = "设备签名", required = true, paramType = "header", dataType = "string") + }) + @PostMapping("/price") + public Map price(HttpServletRequest request) { + Long deviceId = deviceIdFrom(request); + DayCardPriceConfigEntity config = dayCardOrderService.getDevicePrice(deviceId); + Map data = new HashMap<>(); + if (config == null) { + data.put("status", "UNCONFIGURED"); + } else { + data.put("status", "OK"); + data.put("price", config.getPrice()); + data.put("priceFen", config.getPrice().multiply(new java.math.BigDecimal("100")).intValue()); + data.put("name", config.getName()); + } + return buildSuccessResponse(data); + } + + /** + * 创建天卡订单(无权益时调用) + */ + @ApiOperation(value = "创建天卡订单", + notes = "请求头必传 sign。从 sign 解析 deviceId,无需传设备标识。返回 data.orderNo、data.status、data.amount(分)、data.codeUrl、data.expireAt。") + @AuditAction(value = "DAYCARD_ORDER_CREATE", description = "创建天卡订单(设备端)") + @ApiImplicitParams({ + @ApiImplicitParam(name = "sign", value = "设备签名", required = true, paramType = "header", dataType = "string") + }) + @PostMapping("/daycard/create") + public Map createDayCard(HttpServletRequest request) { + try { + Long deviceId = deviceIdFrom(request); + DayCardOrderEntity order = dayCardOrderService.createOrder(deviceId); + Map data = new HashMap<>(); + data.put("orderNo", order.getOrderNo()); + data.put("status", order.getStatus()); + data.put("amount", order.getAmount().multiply(new java.math.BigDecimal("100")).intValue()); + data.put("cardNo", order.getCardNo()); + data.put("codeUrl", order.getCodeUrl()); + data.put("expireAt", order.getExpireTime()); + data.put("deviceId", order.getDeviceId()); + return buildSuccessResponse(data); + } catch (ASException e) { + return rrExceptionResponse(e); + } + } + + /** + * 查询支付状态 + */ + @ApiOperation(value = "查询支付状态", + notes = "请求头必传 sign。路径参数为订单号,返回 data.status(CREATED/PAYING/PAID/CANCELLED)、data.codeUrl 等。") + @ApiImplicitParams({ + @ApiImplicitParam(name = "sign", value = "设备签名", required = true, paramType = "header", dataType = "string") + }) + @GetMapping("/daycard/status/{orderNo}") + public Map daycardStatus(@PathVariable("orderNo") String orderNo) { + DayCardOrderEntity order = dayCardOrderService.getByOrderNo(orderNo); + if (order == null) { + Map err = new HashMap<>(); + err.put("code", 404); + err.put("msg", "订单不存在"); + err.put("data", new HashMap<>()); + return err; + } + Map data = new HashMap<>(); + data.put("orderNo", order.getOrderNo()); + data.put("status", order.getStatus()); + data.put("paidAt", order.getPayTime()); + data.put("codeUrl", order.getCodeUrl()); + data.put("amount", order.getAmount() != null ? order.getAmount().multiply(new java.math.BigDecimal("100")).intValue() : 0); + data.put("payChannel", order.getPayChannel()); + return buildSuccessResponse(data); + } + + /** + * 设备心跳:更新设备心跳时间,续期 sign + */ + @ApiOperation(value = "设备心跳", + notes = "请求头必传 sign。更新设备 last_heartbeat 并续期 sign 缓存 TTL。建议按业务间隔调用以防 sign 过期。") + @ApiImplicitParams({ + @ApiImplicitParam(name = "sign", value = "设备签名", required = true, paramType = "header", dataType = "string") + }) + @PostMapping("/heartBeat") + public Map heartBeat(HttpServletRequest request) { + Long deviceId = deviceIdFrom(request); + DeviceEntity device = deviceService.getById(deviceId); + if (device != null) { + device.setLastHeartbeat(new Date()); + deviceService.updateDevice(device); + } + apiDeviceSignService.refreshExpire(deviceId); + return buildSuccessResponse(new HashMap<>()); + } + + /** + * 刷新 sign:换新 sign,旧 sign 立即失效 + */ + @ApiOperation(value = "刷新 sign", + notes = "请求头必传当前有效 sign。成功后在 data 中返回新 sign;旧 sign 立即失效,客户端必须替换本地存储。") + @ApiImplicitParams({ + @ApiImplicitParam(name = "sign", value = "设备签名(旧的即将过期 sign)", required = true, paramType = "header", dataType = "string") + }) + @PostMapping("/refreshSign") + public Map refreshSign(HttpServletRequest request) { + Long deviceId = deviceIdFrom(request); + String orgCode = channelOrgCodeFrom(request); + String newSign = apiDeviceSignService.generate(deviceId, System.currentTimeMillis(), orgCode); + Map data = new HashMap<>(); + data.put("sign", newSign); + return buildSuccessResponse(data); + } + + // ==================== 内部方法 ==================== + + private Map doInit(DeviceInitRequest request) { + if (request == null) { + throw new ASException("请求体不能为空", 400); + } + String activationCode = trim(request.getActivationCode()); + if (StringUtils.isBlank(activationCode)) { + // 代销模式:免activationCode,按设备标识查找已注册设备(由partner消耗天卡接口创建) + return doInitWithoutActivationCode(request); + } + + // 通过激活码解析门店与渠道 + DayCardActivationCodeEntity codeEntity = dayCardActivationCodeService.getByCode(activationCode); + if (codeEntity == null) { + throw new ASException("激活码无效或已禁用", 400); + } + Long storeOrgId = codeEntity.getStoreOrgId(); + Long channelOrgId = codeEntity.getChannelOrgId(); + if (storeOrgId == null) { + throw new ASException("激活码未绑定门店", 400); + } + + // 校验门店存在 + SysOrgEntity storeOrg = sysOrgService.getById(storeOrgId); + if (storeOrg == null) { + throw new ASException("门店不存在", 400); + } + + // 校验设备数上限 + if (codeEntity.getMaxDeviceCount() != null && codeEntity.getMaxDeviceCount() > 0) { + int activated = codeEntity.getActivatedCount() != null ? codeEntity.getActivatedCount() : 0; + if (activated >= codeEntity.getMaxDeviceCount()) { + throw new ASException("该激活码已达设备上限(" + codeEntity.getMaxDeviceCount() + "台)", 400); + } + } + + String androidId = trim(request.getAndroidId()); + String mac = trim(request.getMac()); + String wifiMac = trim(request.getWifiMac()); + String deviceSn = trim(request.getDeviceSn()); + String deviceUid = trim(request.getDeviceUid()); + + // 查找或创建设备(按优先级降权匹配:androidId > mac > deviceSn > wifiMac > deviceUid) + DeviceEntity device = findOrCreateDevice(androidId, mac, wifiMac, deviceSn, deviceUid); + boolean isNewDevice = device.getId() == null; + Date now = new Date(); + device.setStoreOrgId(storeOrgId); + // 仅用非空标识更新,避免覆盖已有标识(按文档"更新缺失的标识字段") + if (StringUtils.isNotBlank(androidId)) device.setAndroidId(androidId); + if (StringUtils.isNotBlank(mac)) device.setMac(mac); + if (StringUtils.isNotBlank(wifiMac)) device.setWifiMac(wifiMac); + if (StringUtils.isNotBlank(deviceSn)) device.setDeviceSn(deviceSn); + if (StringUtils.isNotBlank(deviceUid)) device.setDeviceUid(deviceUid); + if (StringUtils.isNotBlank(request.getManufacturer())) device.setManufacturer(trim(request.getManufacturer())); + if (StringUtils.isNotBlank(request.getModel())) device.setModel(trim(request.getModel())); + if (StringUtils.isNotBlank(request.getBrand())) device.setBrand(trim(request.getBrand())); + if (StringUtils.isNotBlank(request.getVerCode())) device.setVerCode(trim(request.getVerCode())); + if (StringUtils.isNotBlank(request.getVerName())) device.setVerName(trim(request.getVerName())); + if (StringUtils.isNotBlank(request.getAndroidVersion())) device.setAndroidVersion(trim(request.getAndroidVersion())); + if (StringUtils.isNotBlank(request.getDeviceModel())) device.setDeviceModel(trim(request.getDeviceModel())); + if (StringUtils.isNotBlank(request.getDeviceName())) device.setDeviceName(trim(request.getDeviceName())); + device.setLastHeartbeat(now); + if (isNewDevice) { + device.setCreatedAt(now); + device.setDeleteFlag("UNDEL"); + device.setStatus("ACTIVE"); + deviceService.saveDevice(device); + // 新设备激活计数 +1 + dayCardActivationCodeService.incrementActivatedCount(codeEntity.getId()); + } else { + device.setUpdatedAt(now); + deviceService.updateDevice(device); + } + + // 生成 sign(orgCode 用渠道组织ID的字符串形式,sign 校验时不依赖此值) + String orgCode = channelOrgId != null ? String.valueOf(channelOrgId) : ""; + String sign = apiDeviceSignService.generate(device.getId(), System.currentTimeMillis(), orgCode); + + Map data = new HashMap<>(); + Map deviceInfo = new HashMap<>(); + deviceInfo.put("deviceId", device.getId()); + deviceInfo.put("roomNo", device.getRoomNo()); + data.put("device", deviceInfo); + data.put("sign", sign); + return buildSuccessResponse(data); + } + + /** + * 代销模式免activationCode初始化:按设备标识查找已注册设备,返回sign + *

设备由 /openapi/partner/card/consume 接口创建,APK用同一设备标识调本方法拿sign

+ */ + private Map doInitWithoutActivationCode(DeviceInitRequest request) { + String mac = trim(request.getMac()); + String wifiMac = trim(request.getWifiMac()); + String deviceSn = trim(request.getDeviceSn()); + String deviceUid = trim(request.getDeviceUid()); + if (StringUtils.isBlank(mac) && StringUtils.isBlank(wifiMac) + && StringUtils.isBlank(deviceSn) && StringUtils.isBlank(deviceUid)) { + throw new ASException("代销模式免激活码,需至少传一个设备标识(mac/wifiMac/deviceSn/deviceUid)", 400); + } + // 按优先级查找已有设备:mac > wifiMac > deviceSn > deviceUid(与生产环境一致) + DeviceEntity device = findByIdentifier(mac, DeviceEntity::getMac); + if (device == null) device = findByIdentifier(wifiMac, DeviceEntity::getWifiMac); + if (device == null) device = findByIdentifier(deviceSn, DeviceEntity::getDeviceSn); + if (device == null) device = findByIdentifier(deviceUid, DeviceEntity::getDeviceUid); + if (device == null) { + throw new ASException("设备未注册,请先通过合作伙伴天卡消耗接口激活设备", 400); + } + // 更新心跳 + device.setLastHeartbeat(new Date()); + deviceService.updateDevice(device); + // 生成sign(orgCode留空,代销模式不依赖渠道组织) + String sign = apiDeviceSignService.generate(device.getId(), System.currentTimeMillis(), ""); + Map data = new HashMap<>(); + Map deviceInfo = new HashMap<>(); + deviceInfo.put("deviceId", device.getId()); + deviceInfo.put("roomNo", device.getRoomNo()); + data.put("device", deviceInfo); + Map result = buildSuccessResponse(data); + result.put("sign", sign); + return result; + } + + /** + * 查找或创建设备:按 mac 优先查找,无则新建 + *

注:此处为简化实现,完整的多策略识别算法在 DeviceResolveService 中

+ */ + private DeviceEntity findOrCreateDevice(String androidId, String mac, String wifiMac, String deviceSn, String deviceUid) { + if (StringUtils.isBlank(androidId) && StringUtils.isBlank(mac) + && StringUtils.isBlank(wifiMac) && StringUtils.isBlank(deviceSn) + && StringUtils.isBlank(deviceUid)) { + throw new ASException("androidId、mac、wifiMac、deviceSn、deviceUid 至少传一个", 400); + } + // 按优先级降权匹配:androidId > mac > deviceSn > wifiMac > deviceUid + DeviceEntity existing = findByIdentifier(androidId, DeviceEntity::getAndroidId); + if (existing == null) existing = findByIdentifier(mac, DeviceEntity::getMac); + if (existing == null) existing = findByIdentifier(deviceSn, DeviceEntity::getDeviceSn); + if (existing == null) existing = findByIdentifier(wifiMac, DeviceEntity::getWifiMac); + if (existing == null) existing = findByIdentifier(deviceUid, DeviceEntity::getDeviceUid); + return existing != null ? existing : new DeviceEntity(); + } + + private DeviceEntity findByIdentifier(String value, com.baomidou.mybatisplus.core.toolkit.support.SFunction column) { + if (StringUtils.isBlank(value)) { + return null; + } + return deviceService.lambdaQuery() + .eq(column, value) + .eq(DeviceEntity::getDeleteFlag, "UNDEL") + .last("LIMIT 1").one(); + } + + private static Long deviceIdFrom(HttpServletRequest request) { + return (Long) request.getAttribute(ApiDeviceSignFilter.REQUEST_ATTR_DEVICE_ID); + } + + private static String channelOrgCodeFrom(HttpServletRequest request) { + DeviceSignResult signResult = + (DeviceSignResult) request.getAttribute(ApiDeviceSignFilter.REQUEST_ATTR_DEVICE_SIGN_RESULT); + return signResult != null ? signResult.getOrgCode() : null; + } + + private static String trim(String s) { + return s == null ? null : s.trim(); + } + + private static Map buildSuccessResponse(Map data) { + Map result = new HashMap<>(); + result.put("code", 0); + result.put("msg", "success"); + result.put("data", data != null ? data : new HashMap<>()); + return result; + } + + private static Map rrExceptionResponse(ASException e) { + Map err = new HashMap<>(); + err.put("code", e.getCode()); + err.put("msg", e.getMessage()); + err.put("data", new HashMap<>()); + return err; + } +} diff --git a/src/main/java/io/as/modules/openapi/controller/ApiJstUiController.java b/src/main/java/io/as/modules/openapi/controller/ApiJstUiController.java new file mode 100644 index 0000000..bfc4ee1 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/controller/ApiJstUiController.java @@ -0,0 +1,253 @@ +package io.as.modules.openapi.controller; + +import io.as.modules.device.dto.DeviceOrgContext; +import io.as.modules.device.service.DeviceResolveService; +import io.as.modules.openapi.filter.ApiDeviceSignFilter; +import io.as.modules.sys.entity.SysOrgEntity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 橘视听 App UI 数据接口 + *

提供顶栏按钮、筛选分类、筛选内容列表、独立配置等 App 展示所需数据

+ *

所有接口均需请求头 sign(由 ApiDeviceSignFilter 校验)

+ *

返回格式:{code:0, msg:"success", data:{...}}

+ */ +@RestController +@RequestMapping("/api/jst") +public class ApiJstUiController { + + private static final Logger logger = LoggerFactory.getLogger(ApiJstUiController.class); + + @Autowired + private DeviceResolveService deviceResolveService; + + @Value("${renren.daycard.ui.logo-url:https://daycard.oss.cn/logo.png}") + private String defaultLogoUrl; + + @Value("${renren.daycard.ui.login-bg-url:https://daycard.oss.cn/login_bg.png}") + private String defaultLoginBgUrl; + + @Value("${renren.daycard.ui.search-normal-url:https://daycard.oss.cn/search_normal.png}") + private String searchNormalUrl; + + @Value("${renren.daycard.ui.search-focus-url:https://daycard.oss.cn/search_focus.png}") + private String searchFocusUrl; + + @Value("${renren.daycard.ui.help-normal-url:https://daycard.oss.cn/help_normal.png}") + private String helpNormalUrl; + + @Value("${renren.daycard.ui.help-focus-url:https://daycard.oss.cn/help_focus.png}") + private String helpFocusUrl; + + // ==================== 1. 顶栏按钮 ==================== + + /** + * 顶栏按钮数据:返回 logo + 搜索 + 帮助 + *

logo 优先取设备所属渠道的 brand_logo_url,无则用全局默认

+ */ + @PostMapping("/topButtons") + public Map topButtons(@RequestBody(required = false) Map body, + HttpServletRequest request) { + Long deviceId = deviceIdFrom(request); + String logoUrl = resolveLogoUrl(deviceId); + + List> buttons = new ArrayList<>(); + + // type=1 Logo(仅 back_url) + Map logoBtn = new HashMap<>(); + logoBtn.put("type", 1); + logoBtn.put("back_url", logoUrl); + buttons.add(logoBtn); + + // type=3 搜索(back_url + focus_picurl) + Map searchBtn = new HashMap<>(); + searchBtn.put("type", 3); + searchBtn.put("back_url", searchNormalUrl); + searchBtn.put("focus_picurl", searchFocusUrl); + buttons.add(searchBtn); + + // type=6 帮助(back_url + focus_picurl) + Map helpBtn = new HashMap<>(); + helpBtn.put("type", 6); + helpBtn.put("back_url", helpNormalUrl); + helpBtn.put("focus_picurl", helpFocusUrl); + buttons.add(helpBtn); + + return buildJstResponse(buttons); + } + + // ==================== 2. 筛选分类 ==================== + + /** + * 筛选分类:返回栏目/类型/地区/年代四级分类 + *

当前为固定分类数据,后续可对接橘视厅媒资 API

+ */ + @PostMapping("/filter/tags") + public Map filterTags(@RequestBody(required = false) Map body) { + List> series = new ArrayList<>(); + + // type=1 栏目 + series.add(buildSeries(1, "全部栏目", new String[][]{ + {"all", "全部"}, {"movie", "电影"}, {"tv", "电视剧"}, {"variety", "综艺"}, {"anime", "动漫"} + })); + + // type=2 类型 + series.add(buildSeries(2, "全部类型", new String[][]{ + {"all", "全部"}, {"action", "动作"}, {"comedy", "喜剧"}, {"romance", "爱情"}, + {"scifi", "科幻"}, {"suspense", "悬疑"}, {"animation", "动画"} + })); + + // type=3 地区 + series.add(buildSeries(3, "全部地区", new String[][]{ + {"all", "全部"}, {"cn", "内地"}, {"hk", "中国香港"}, {"tw", "中国台湾"}, + {"us", "美国"}, {"kr", "韩国"}, {"jp", "日本"}, {"uk", "英国"} + })); + + // type=4 年代 + series.add(buildSeries(4, "全部年代", new String[][]{ + {"all", "全部"}, {"2026", "2026"}, {"2025", "2025"}, {"2024", "2024"}, + {"2023", "2023"}, {"2022", "2022"}, {"2021", "2021"} + })); + + Map data = new HashMap<>(); + data.put("series", series); + return buildJstResponse(data); + } + + // ==================== 3. 筛选内容列表 ==================== + + /** + * 筛选内容列表:根据筛选条件返回内容列表 + *

TODO: 后续对接橘视厅媒资 API 或自建媒资表,当前返回 mock 数据骨架

+ */ + @PostMapping("/filter/content") + public Map filterContent(@RequestBody Map body) { + int page = parseInt(body.get("page"), 1); + int pageSize = parseInt(body.get("pageSize"), 20); + + // TODO: 对接真实媒资数据源,当前返回 mock 数据 + List> list = new ArrayList<>(); + String[][] mockContents = { + {"MOV001", "测试影片一", "https://daycard.oss.cn/poster/1.jpg", "8", "true"}, + {"MOV002", "测试影片二", "https://daycard.oss.cn/poster/2.jpg", "1", "false"}, + {"MOV003", "测试影片三", "https://daycard.oss.cn/poster/3.jpg", "8", "true"}, + {"MOV004", "测试影片四", "https://daycard.oss.cn/poster/4.jpg", "1", "false"}, + {"MOV005", "测试影片五", "https://daycard.oss.cn/poster/5.jpg", "8", "true"} + }; + for (String[] item : mockContents) { + Map content = new HashMap<>(); + content.put("contentCode", item[0]); + content.put("title", item[1]); + content.put("posterUrl", item[2]); + content.put("parentContentType", item[3]); + content.put("isFree", Boolean.parseBoolean(item[4])); + list.add(content); + } + + Map data = new HashMap<>(); + data.put("total", 5); + data.put("currentPage", page); + data.put("totalPage", 1); + data.put("list", list); + return buildJstResponse(data); + } + + // ==================== 4. 独立配置接口 ==================== + + /** + * 独立配置:返回 logoUrl + loginBg + *

logoUrl 优先取设备所属渠道的 brand_logo_url

+ */ + @GetMapping("/config") + public Map config(@RequestParam(required = false) String deviceId, HttpServletRequest request) { + Long devId = deviceIdFrom(request); + if (devId == null && deviceId != null) { + try { + devId = Long.parseLong(deviceId); + } catch (NumberFormatException ignored) { + } + } + String logoUrl = resolveLogoUrl(devId); + + Map data = new HashMap<>(); + data.put("logoUrl", logoUrl); + data.put("loginBg", defaultLoginBgUrl); + return buildJstResponse(data); + } + + // ==================== 内部方法 ==================== + + /** + * 从设备所属渠道解析 logoUrl,无则用全局默认 + */ + private String resolveLogoUrl(Long deviceId) { + if (deviceId == null) { + return defaultLogoUrl; + } + try { + DeviceOrgContext ctx = deviceResolveService.resolveDevice(deviceId); + SysOrgEntity channelOrg = ctx.getChannelOrg(); + if (channelOrg != null && channelOrg.getBrandLogoUrl() != null + && !channelOrg.getBrandLogoUrl().trim().isEmpty()) { + return channelOrg.getBrandLogoUrl().trim(); + } + } catch (Exception e) { + logger.warn("解析设备渠道logo失败,使用默认logo: deviceId={}", deviceId, e); + } + return defaultLogoUrl; + } + + /** + * 构建筛选分类维度 + */ + private Map buildSeries(int type, String name, String[][] items) { + Map series = new HashMap<>(); + series.put("type", type); + series.put("name", name); + List> itemList = new ArrayList<>(); + for (String[] item : items) { + Map m = new HashMap<>(); + m.put("id", item[0]); + m.put("name", item[1]); + itemList.add(m); + } + series.put("items", itemList); + return series; + } + + /** + * 构建统一响应 + */ + private static Map buildJstResponse(Object data) { + Map result = new HashMap<>(); + result.put("code", 0); + result.put("msg", "success"); + result.put("data", data); + return result; + } + + private static Long deviceIdFrom(HttpServletRequest request) { + return (Long) request.getAttribute(ApiDeviceSignFilter.REQUEST_ATTR_DEVICE_ID); + } + + private static int parseInt(Object val, int defaultValue) { + if (val == null) { + return defaultValue; + } + try { + return Integer.parseInt(String.valueOf(val)); + } catch (NumberFormatException e) { + return defaultValue; + } + } +} diff --git a/src/main/java/io/as/modules/openapi/controller/ContentPackageController.java b/src/main/java/io/as/modules/openapi/controller/ContentPackageController.java new file mode 100644 index 0000000..51eba6d --- /dev/null +++ b/src/main/java/io/as/modules/openapi/controller/ContentPackageController.java @@ -0,0 +1,219 @@ +package io.as.modules.openapi.controller; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import io.as.common.annotation.AuditAction; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.modules.media.client.JetsenMediaClient; +import io.as.modules.openapi.entity.ContentPackageEntity; +import io.as.modules.openapi.service.ContentPackageService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +/** + * 内容包管理 + */ +@Api(tags = "内容包管理") +@RestController +@RequestMapping("/manage/content-package") +public class ContentPackageController { + + @Autowired + private ContentPackageService contentPackageService; + @Autowired + private JetsenMediaClient jetsenMediaClient; + + /** + * 分页查询内容包列表 + */ + @ApiOperation("分页查询内容包列表") + @GetMapping("/list") + public R list(@RequestParam Map params) { + PageUtils page = contentPackageService.queryPage(params); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + /** + * 内容包详情 + */ + @ApiOperation("内容包详情") + @GetMapping("/info/{id}") + public R info(@PathVariable("id") Long id) { + ContentPackageEntity contentPackage = contentPackageService.getById(id); + Map data = new HashMap<>(); + data.put("contentPackage", contentPackage); + data.put("contentList", parseContentList(contentPackage)); + return R.dataSuccess(data); + } + + /** + * 按合作伙伴ID查询内容包授权配置 + */ + @ApiOperation("按合作伙伴ID查询内容包授权配置") + @GetMapping("/info-by-partner/{partnerId}") + public R infoByPartner(@PathVariable("partnerId") String partnerId) { + ContentPackageEntity contentPackage = contentPackageService.getByPartnerId(partnerId); + Map data = new HashMap<>(); + data.put("contentPackage", contentPackage); + data.put("contentList", parseContentList(contentPackage)); + return R.dataSuccess(data); + } + + /** + * 新增内容包 + */ + @ApiOperation("新增内容包") + @AuditAction(value = "PACKAGE_SAVE", description = "新增内容包") + @PostMapping("/save") + public R save(@RequestBody ContentPackageEntity contentPackage) { + Date now = new Date(); + contentPackage.setDeleteFlag("UNDEL"); + contentPackage.setCreatedAt(now); + contentPackage.setUpdatedAt(now); + contentPackageService.saveOrUpdatePackage(contentPackage); + Map data = new HashMap<>(); + data.put("contentPackage", contentPackage); + return R.dataSuccess(data); + } + + /** + * 修改内容包 + */ + @ApiOperation("修改内容包") + @AuditAction(value = "PACKAGE_UPDATE", description = "修改内容包") + @PostMapping("/update") + public R update(@RequestBody ContentPackageEntity contentPackage) { + if (contentPackage.getId() == null) { + throw new ASException("id不能为空"); + } + contentPackage.setUpdatedAt(new Date()); + contentPackageService.updateById(contentPackage); + return R.ok(); + } + + /** + * 删除内容包(软删除) + */ + @ApiOperation("删除内容包") + @AuditAction(value = "PACKAGE_DELETE", description = "删除内容包") + @PostMapping("/delete") + public R delete(@RequestBody Long[] ids) { + for (Long id : ids) { + ContentPackageEntity contentPackage = new ContentPackageEntity(); + contentPackage.setId(id); + contentPackage.setDeleteFlag("DEL"); + contentPackage.setUpdatedAt(new Date()); + contentPackageService.updateById(contentPackage); + } + return R.ok(); + } + + // ==================== 内容选择辅助接口 ==================== + + /** 解析 content_codes JSON 字符串为数组,方便前端展示 */ + private List parseContentList(ContentPackageEntity pkg) { + if (pkg == null || pkg.getContentCodes() == null || pkg.getContentCodes().isEmpty()) { + return new ArrayList<>(); + } + try { + return JSON.parseArray(pkg.getContentCodes()); + } catch (Exception e) { + return new ArrayList<>(); + } + } + + /** + * 拉取栏目内容预览:从橘视厅实时拉取指定栏目的内容,返回精简字段供管理员勾选 + */ + @ApiOperation("拉取栏目内容预览(供选择)") + @GetMapping("/fetch-contents") + public R fetchContents(@RequestParam String catalogid, + @RequestParam(defaultValue = "1") int pageindex, + @RequestParam(defaultValue = "50") int pagesize) { + JSONObject resp = jetsenMediaClient.getContentList(catalogid, pageindex, pagesize); + List> list = new ArrayList<>(); + Object result = resp.get("result"); + if (result instanceof JSONArray) { + for (Object o : (JSONArray) result) { + if (o instanceof JSONObject) { + JSONObject item = (JSONObject) o; + Map simplified = new LinkedHashMap<>(); + simplified.put("contentcode", item.getString("contentcode")); + simplified.put("name", item.getString("name")); + simplified.put("posterimg", item.getString("posterimg")); + simplified.put("categoryname", item.getString("categoryname")); + simplified.put("score", item.getString("score")); + simplified.put("isFree", item.getString("isFree")); + simplified.put("episodecount", item.get("episodecount")); + simplified.put("duration", item.get("duration")); + simplified.put("releaseyear", item.getString("releaseyear")); + simplified.put("originalcountry", item.getString("originalcountry")); + list.add(simplified); + } + } + } + Map data = new HashMap<>(); + data.put("list", list); + data.put("totalCount", resp.getIntValue("sizetotal")); + data.put("totalPage", resp.getIntValue("pagetotal")); + data.put("currPage", pageindex); + data.put("pageSize", pagesize); + return R.dataSuccess(data); + } + + /** + * 选择内容保存内容包:管理员勾选内容后一键保存,自动序列化 content_codes + *

请求体:{partnerId, name, catalogIds(可选), selectedContents:[{contentcode,name,posterimg,...}]}

+ */ + @ApiOperation("选择内容保存内容包") + @AuditAction(value = "PACKAGE_SAVE_FROM_SELECTION", description = "选择内容保存内容包") + @PostMapping("/save-from-selection") + public R saveFromSelection(@RequestBody Map body) { + String partnerId = (String) body.get("partnerId"); + if (partnerId == null || partnerId.trim().isEmpty()) { + throw new ASException("partnerId不能为空"); + } + String name = body.get("name") != null ? String.valueOf(body.get("name")) : null; + String catalogIds = body.get("catalogIds") != null ? String.valueOf(body.get("catalogIds")) : null; + + // 勾选的内容对象列表 -> 序列化为JSON字符串存入 content_codes + Object selectedContents = body.get("selectedContents"); + String contentCodes = null; + if (selectedContents != null) { + contentCodes = JSON.toJSONString(selectedContents); + } + + ContentPackageEntity entity = null; + if (body.get("id") != null) { + entity = contentPackageService.getById(Long.valueOf(String.valueOf(body.get("id")))); + } else { + entity = contentPackageService.getByPartnerId(partnerId); + } + boolean isNew = entity == null; + if (isNew) { + entity = new ContentPackageEntity(); + entity.setPartnerId(partnerId); + entity.setDeleteFlag("UNDEL"); + entity.setCreatedAt(new Date()); + } + if (name != null) entity.setName(name); + entity.setCatalogIds(catalogIds); + entity.setContentCodes(contentCodes); + entity.setStatus("ACTIVE"); + entity.setUpdatedAt(new Date()); + contentPackageService.saveOrUpdatePackage(entity); + + Map data = new HashMap<>(); + data.put("contentPackage", entity); + return R.dataSuccess(data); + } +} diff --git a/src/main/java/io/as/modules/openapi/controller/OpenApiCallLogController.java b/src/main/java/io/as/modules/openapi/controller/OpenApiCallLogController.java new file mode 100644 index 0000000..3891d89 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/controller/OpenApiCallLogController.java @@ -0,0 +1,36 @@ +package io.as.modules.openapi.controller; + +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.modules.openapi.service.OpenApiCallLogService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +/** + * 开放API调用日志管理(管理后台,JWT鉴权) + */ +@Api(tags = "开放API调用日志") +@RestController +@RequestMapping("/manage/openapi/call-log") +public class OpenApiCallLogController { + + @Autowired + private OpenApiCallLogService openApiCallLogService; + + @ApiOperation("分页查询调用日志") + @GetMapping("/list") + public R list(@RequestParam Map params) { + PageUtils page = openApiCallLogService.queryPage(params); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } +} diff --git a/src/main/java/io/as/modules/openapi/controller/OpenApiMediaController.java b/src/main/java/io/as/modules/openapi/controller/OpenApiMediaController.java new file mode 100644 index 0000000..4d3201e --- /dev/null +++ b/src/main/java/io/as/modules/openapi/controller/OpenApiMediaController.java @@ -0,0 +1,286 @@ +package io.as.modules.openapi.controller; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import io.as.common.utils.IPUtils; +import io.as.common.utils.R; +import io.as.modules.media.client.JetsenMediaClient; +import io.as.modules.openapi.entity.ContentPackageEntity; +import io.as.modules.openapi.filter.PartnerSignFilter; +import io.as.modules.openapi.service.ContentPackageService; +import io.as.modules.openapi.service.OpenApiCallLogService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import java.util.*; + +/** + * 开放API - 媒资接口(第三方合作伙伴调用,appKey/appSecret 签名鉴权) + *

三级授权过滤:content_codes(单部内容) > catalog_ids(栏目) > 全量

+ *

鉴权由 PartnerSignFilter 完成,本 Controller 从 request 属性取 partnerId。

+ */ +@Api(tags = "开放API-媒资接口") +@RestController +@RequestMapping("/openapi/partner/media") +public class OpenApiMediaController { + + private static final Logger logger = LoggerFactory.getLogger(OpenApiMediaController.class); + + @Autowired + private JetsenMediaClient jetsenMediaClient; + @Autowired + private ContentPackageService contentPackageService; + @Autowired + private OpenApiCallLogService openApiCallLogService; + + // ==================== 授权解析 ==================== + + private String partnerIdFrom(HttpServletRequest req) { + return (String) req.getAttribute(PartnerSignFilter.REQUEST_ATTR_PARTNER_ID); + } + + /** 解析授权内容code集合,非空表示CONTENT级别 */ + private Set parseContentCodes(ContentPackageEntity pkg) { + if (pkg == null || StringUtils.isBlank(pkg.getContentCodes())) { + return null; + } + try { + JSONArray arr = JSON.parseArray(pkg.getContentCodes()); + Set codes = new HashSet<>(); + for (Object o : arr) { + if (o instanceof JSONObject) { + String code = ((JSONObject) o).getString("contentcode"); + if (code != null) { + codes.add(code); + } + } + } + return codes.isEmpty() ? null : codes; + } catch (Exception e) { + return null; + } + } + + /** 解析授权栏目id集合,非空表示CATALOG级别 */ + private Set parseCatalogIds(ContentPackageEntity pkg) { + if (pkg == null || StringUtils.isBlank(pkg.getCatalogIds())) { + return null; + } + Set ids = new HashSet<>(); + for (String s : pkg.getCatalogIds().split(",")) { + String t = s.trim(); + if (!t.isEmpty()) { + ids.add(t); + } + } + return ids.isEmpty() ? null : ids; + } + + /** 判定授权级别 */ + private String scopeOf(ContentPackageEntity pkg) { + if (parseContentCodes(pkg) != null) { + return "CONTENT"; + } + if (parseCatalogIds(pkg) != null) { + return "CATALOG"; + } + return "FULL"; + } + + // ==================== 接口 ==================== + + /** 查看我的内容包授权配置 */ + @ApiOperation("我的内容包配置") + @GetMapping("/package") + public R packageInfo(HttpServletRequest req) { + long start = System.currentTimeMillis(); + String partnerId = partnerIdFrom(req); + ContentPackageEntity pkg = contentPackageService.getByPartnerId(partnerId); + Map data = new HashMap<>(); + if (pkg == null) { + data.put("scope", "FULL"); + data.put("catalogIds", null); + data.put("contentCount", 0); + } else { + Set codes = parseContentCodes(pkg); + data.put("scope", scopeOf(pkg)); + data.put("catalogIds", pkg.getCatalogIds()); + data.put("contentCount", codes != null ? codes.size() : 0); + } + logCall(req, "/package", "GET", 0, start); + return R.dataSuccess(data); + } + + /** 栏目树(按授权过滤) */ + @ApiOperation("栏目树") + @GetMapping("/catalog-tree") + public R catalogTree(HttpServletRequest req) { + long start = System.currentTimeMillis(); + String partnerId = partnerIdFrom(req); + ContentPackageEntity pkg = contentPackageService.getByPartnerId(partnerId); + Set contentCodes = parseContentCodes(pkg); + Set catalogIds = parseCatalogIds(pkg); + + JSONArray tree; + if (contentCodes != null) { + // CONTENT级别:不按栏目,返回空树 + tree = new JSONArray(); + } else { + tree = jetsenMediaClient.getCatalogTree(); + if (catalogIds != null) { + // CATALOG级别:只保留授权的二级栏目 + JSONArray filtered = new JSONArray(); + for (Object o : tree) { + JSONObject node = (JSONObject) o; + JSONArray children = node.getJSONArray("children"); + JSONArray kept = new JSONArray(); + if (children != null) { + for (Object c : children) { + JSONObject child = (JSONObject) c; + if (catalogIds.contains(String.valueOf(child.get("id")))) { + kept.add(child); + } + } + } + node.put("children", kept); + if (!kept.isEmpty()) { + filtered.add(node); + } + } + tree = filtered; + } + // FULL: 返回完整树 + } + logCall(req, "/catalog-tree", "GET", 0, start); + Map data = new HashMap<>(); + data.put("list", tree); + return R.dataSuccess(data); + } + + /** 内容列表(按授权过滤) */ + @ApiOperation("内容列表") + @GetMapping("/list") + public R list(@RequestParam(required = false) String catalogid, + @RequestParam(defaultValue = "1") int pageindex, + @RequestParam(defaultValue = "20") int pagesize, + HttpServletRequest req) { + long start = System.currentTimeMillis(); + String partnerId = partnerIdFrom(req); + ContentPackageEntity pkg = contentPackageService.getByPartnerId(partnerId); + Set contentCodes = parseContentCodes(pkg); + Set catalogIds = parseCatalogIds(pkg); + Map data = new HashMap<>(); + + if (contentCodes != null) { + // CONTENT级别:返回本地存储的授权内容,本地分页 + JSONArray all = JSON.parseArray(pkg.getContentCodes()); + int total = all.size(); + int from = Math.max(0, (pageindex - 1) * pagesize); + int to = Math.min(from + pagesize, total); + data.put("list", from < total ? all.subList(from, to) : new ArrayList<>()); + data.put("totalCount", total); + data.put("totalPage", (total + pagesize - 1) / pagesize); + } else if (catalogIds != null) { + // CATALOG级别:校验catalogid在授权范围内 + if (StringUtils.isBlank(catalogid) || !catalogIds.contains(catalogid)) { + logCall(req, "/list", "GET", 400, start); + return R.error("catalogid不在授权范围内,可用栏目: " + pkg.getCatalogIds()); + } + JSONObject resp = jetsenMediaClient.getContentList(catalogid, pageindex, pagesize); + data.put("list", extractList(resp)); + data.put("totalCount", resp.getIntValue("sizetotal")); + data.put("totalPage", resp.getIntValue("pagetotal")); + } else { + // FULL级别:直接查 + if (StringUtils.isBlank(catalogid)) { + logCall(req, "/list", "GET", 400, start); + return R.error("catalogid不能为空"); + } + JSONObject resp = jetsenMediaClient.getContentList(catalogid, pageindex, pagesize); + data.put("list", extractList(resp)); + data.put("totalCount", resp.getIntValue("sizetotal")); + data.put("totalPage", resp.getIntValue("pagetotal")); + } + data.put("currPage", pageindex); + data.put("pageSize", pagesize); + logCall(req, "/list", "GET", 0, start); + return R.dataSuccess(data); + } + + /** 搜索(按授权过滤) */ + @ApiOperation("搜索") + @GetMapping("/search") + public R search(@RequestParam String keyword, + @RequestParam(defaultValue = "1") int pageindex, + @RequestParam(defaultValue = "20") int pagesize, + HttpServletRequest req) { + long start = System.currentTimeMillis(); + String partnerId = partnerIdFrom(req); + ContentPackageEntity pkg = contentPackageService.getByPartnerId(partnerId); + Set contentCodes = parseContentCodes(pkg); + + JSONObject resp = jetsenMediaClient.search(keyword, pageindex, pagesize); + List list = extractList(resp); + + Map data = new HashMap<>(); + if (contentCodes != null) { + // CONTENT级别:按contentcode过滤搜索结果 + list = new ArrayList<>(list); + list.removeIf(item -> !contentCodes.contains(item.getString("contentcode"))); + data.put("list", list); + data.put("totalCount", list.size()); + data.put("totalPage", 1); + } else { + // CATALOG/FULL:直接返回搜索结果(CATALOG级别在list接口已做栏目校验) + data.put("list", list); + data.put("totalCount", resp.getIntValue("sizetotal")); + data.put("totalPage", resp.getIntValue("pagetotal")); + } + data.put("currPage", pageindex); + data.put("pageSize", pagesize); + logCall(req, "/search", "GET", 0, start); + return R.dataSuccess(data); + } + + // ==================== 内部方法 ==================== + + @SuppressWarnings("unchecked") + private List extractList(JSONObject resp) { + List list = new ArrayList<>(); + if (resp == null) { + return list; + } + Object result = resp.get("result"); + if (result instanceof JSONArray) { + for (Object o : (JSONArray) result) { + if (o instanceof JSONObject) { + list.add((JSONObject) o); + } + } + } + return list; + } + + private void logCall(HttpServletRequest req, String api, String method, int resultCode, long startMs) { + try { + String partnerId = (String) req.getAttribute(PartnerSignFilter.REQUEST_ATTR_PARTNER_ID); + String appKey = (String) req.getAttribute(PartnerSignFilter.REQUEST_ATTR_APP_KEY); + String params = req.getQueryString(); + String ip = IPUtils.getIpAddr(req); + int costMs = (int) (System.currentTimeMillis() - startMs); + openApiCallLogService.saveLog(partnerId, appKey, "/openapi/partner/media" + api, method, params, resultCode, ip, costMs); + } catch (Exception e) { + logger.warn("调用日志记录失败: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/io/as/modules/openapi/controller/OpenApiPartnerCardController.java b/src/main/java/io/as/modules/openapi/controller/OpenApiPartnerCardController.java new file mode 100644 index 0000000..f2f6561 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/controller/OpenApiPartnerCardController.java @@ -0,0 +1,100 @@ +package io.as.modules.openapi.controller; + +import io.as.common.utils.IPUtils; +import io.as.common.utils.R; +import io.as.modules.openapi.filter.PartnerSignFilter; +import io.as.modules.openapi.service.OpenApiCallLogService; +import io.as.modules.openapi.service.PartnerCardPoolService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; +import java.util.HashMap; +import java.util.Map; + +/** + * 开放API - 合作伙伴天卡接口(代销模式,appKey/appSecret 签名鉴权) + *

第三方EPG调消耗接口:从partner池子分配天卡绑定设备,APK后续用同一设备标识调init/checkBenefits播放

+ */ +@Api(tags = "开放API-天卡(代销)") +@RestController +@RequestMapping("/openapi/partner/card") +public class OpenApiPartnerCardController { + + private static final Logger logger = LoggerFactory.getLogger(OpenApiPartnerCardController.class); + + @Autowired + private PartnerCardPoolService partnerCardPoolService; + @Autowired + private OpenApiCallLogService openApiCallLogService; + + /** + * 消耗天卡(代销模式) + *

第三方EPG客人付费后调用,从partner池子分配1张天卡绑定到设备,异步激活。

+ *

APK后续用同一设备标识调 /api/jst/init(免activationCode)+ checkBenefits 拿天卡播放。

+ * + * @param body {mac, deviceUid, deviceSn, channelOrgId} + */ + @ApiOperation("消耗天卡(代销)") + @PostMapping("/consume") + public R consume(@RequestBody Map body, HttpServletRequest req) { + long start = System.currentTimeMillis(); + String partnerId = (String) req.getAttribute(PartnerSignFilter.REQUEST_ATTR_PARTNER_ID); + String mac = body.get("mac") != null ? String.valueOf(body.get("mac")) : null; + String wifiMac = body.get("wifiMac") != null ? String.valueOf(body.get("wifiMac")) : null; + String deviceUid = body.get("deviceUid") != null ? String.valueOf(body.get("deviceUid")) : null; + String deviceSn = body.get("deviceSn") != null ? String.valueOf(body.get("deviceSn")) : null; + Long channelOrgId = body.get("channelOrgId") != null ? Long.valueOf(String.valueOf(body.get("channelOrgId"))) : null; + + // 构建审计参数(记录设备标识,用于溯源) + String auditParams = "mac=" + mac + ",wifiMac=" + wifiMac + ",deviceSn=" + deviceSn + ",deviceUid=" + deviceUid; + int resultCode = 0; + try { + Map result = partnerCardPoolService.consume(partnerId, mac, wifiMac, deviceUid, deviceSn, channelOrgId); + return R.dataSuccess(result); + } catch (Exception e) { + resultCode = e instanceof io.as.common.exception.ASException ? ((io.as.common.exception.ASException) e).getCode() : 500; + throw e; + } finally { + logCall(req, "/card/consume", "POST", auditParams, resultCode, start); + } + } + + /** + * 查询我的天卡库存 + */ + @ApiOperation("我的天卡库存") + @GetMapping("/stock") + public R stock(HttpServletRequest req) { + long start = System.currentTimeMillis(); + int resultCode = 0; + try { + String partnerId = (String) req.getAttribute(PartnerSignFilter.REQUEST_ATTR_PARTNER_ID); + int available = partnerCardPoolService.getAvailableCount(partnerId); + Map data = new HashMap<>(); + data.put("available", available); + return R.dataSuccess(data); + } catch (Exception e) { + resultCode = 500; + throw e; + } finally { + logCall(req, "/card/stock", "GET", null, resultCode, start); + } + } + + private void logCall(HttpServletRequest req, String api, String method, String params, int resultCode, long startMs) { + try { + String partnerId = (String) req.getAttribute(PartnerSignFilter.REQUEST_ATTR_PARTNER_ID); + String appKey = (String) req.getAttribute(PartnerSignFilter.REQUEST_ATTR_APP_KEY); + String ip = IPUtils.getIpAddr(req); + int costMs = (int) (System.currentTimeMillis() - startMs); + openApiCallLogService.saveLog(partnerId, appKey, "/openapi/partner" + api, method, params, resultCode, ip, costMs); + } catch (Exception e) { + logger.warn("调用日志记录失败: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/io/as/modules/openapi/controller/OpenApiPartnerController.java b/src/main/java/io/as/modules/openapi/controller/OpenApiPartnerController.java new file mode 100644 index 0000000..1092df9 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/controller/OpenApiPartnerController.java @@ -0,0 +1,112 @@ +package io.as.modules.openapi.controller; + +import io.as.common.annotation.AuditAction; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.modules.openapi.constants.OpenApiConstants; +import io.as.modules.openapi.entity.OpenApiPartnerEntity; +import io.as.modules.openapi.service.OpenApiPartnerService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * 合作伙伴管理 + */ +@Api(tags = "合作伙伴管理") +@RestController +@RequestMapping("/manage/partner") +public class OpenApiPartnerController { + + @Autowired + private OpenApiPartnerService openApiPartnerService; + + /** + * 分页查询合作伙伴列表 + */ + @ApiOperation("分页查询合作伙伴列表") + @GetMapping("/list") + public R list(@RequestParam Map params) { + PageUtils page = openApiPartnerService.queryPage(params); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + /** + * 合作伙伴详情 + */ + @ApiOperation("合作伙伴详情") + @GetMapping("/info/{id}") + public R info(@PathVariable("id") Long id) { + OpenApiPartnerEntity partner = openApiPartnerService.getById(id); + Map data = new HashMap<>(); + data.put("partner", partner); + return R.dataSuccess(data); + } + + /** + * 新增合作伙伴:自动生成 partnerId、appKey、appSecret + */ + @ApiOperation("新增合作伙伴") + @AuditAction(value = "PARTNER_SAVE", description = "新增合作伙伴") + @PostMapping("/save") + public R save(@RequestBody OpenApiPartnerEntity partner) { + String uuid = UUID.randomUUID().toString().replace("-", ""); + partner.setPartnerId(uuid.substring(0, 16)); + partner.setAppKey(UUID.randomUUID().toString().replace("-", "").substring(0, 16)); + partner.setAppSecret(UUID.randomUUID().toString().replace("-", "")); + if (partner.getStatus() == null) { + partner.setStatus(OpenApiConstants.STATUS_ACTIVE); + } + if (partner.getRateLimit() == null) { + partner.setRateLimit(100); + } + partner.setDeleteFlag("UNDEL"); + partner.setCreatedAt(new Date()); + partner.setUpdatedAt(new Date()); + openApiPartnerService.save(partner); + Map data = new HashMap<>(); + data.put("partner", partner); + return R.dataSuccess(data); + } + + /** + * 修改合作伙伴 + */ + @ApiOperation("修改合作伙伴") + @AuditAction(value = "PARTNER_UPDATE", description = "修改合作伙伴") + @PostMapping("/update") + public R update(@RequestBody OpenApiPartnerEntity partner) { + if (partner.getId() == null) { + throw new ASException("id不能为空"); + } + partner.setUpdatedAt(new Date()); + openApiPartnerService.updateById(partner); + return R.ok(); + } + + /** + * 删除合作伙伴(软删除) + */ + @ApiOperation("删除合作伙伴") + @AuditAction(value = "PARTNER_DELETE", description = "删除合作伙伴") + @PostMapping("/delete") + public R delete(@RequestBody Long[] ids) { + for (Long id : ids) { + OpenApiPartnerEntity partner = new OpenApiPartnerEntity(); + partner.setId(id); + partner.setDeleteFlag("DEL"); + partner.setUpdatedAt(new Date()); + openApiPartnerService.updateById(partner); + } + return R.ok(); + } +} diff --git a/src/main/java/io/as/modules/openapi/controller/PartnerCardPurchaseController.java b/src/main/java/io/as/modules/openapi/controller/PartnerCardPurchaseController.java new file mode 100644 index 0000000..0b5fb37 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/controller/PartnerCardPurchaseController.java @@ -0,0 +1,90 @@ +package io.as.modules.openapi.controller; + +import io.as.common.annotation.AuditAction; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.modules.openapi.service.PartnerCardPoolService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 合作伙伴天卡池(代销) + */ +@Api(tags = "合作伙伴天卡池(代销)") +@RestController +@RequestMapping("/manage/partner-card") +public class PartnerCardPurchaseController { + + @Autowired + private PartnerCardPoolService partnerCardPoolService; + + /** + * 采购天卡到合作伙伴池子 + */ + @ApiOperation("采购天卡") + @AuditAction(value = "PARTNER_CARD_PURCHASE", description = "采购天卡到合作伙伴池子") + @PostMapping("/purchase") + public R purchase(@RequestParam("partnerId") String partnerId, + @RequestParam("quantity") int quantity) { + if (partnerId == null || partnerId.trim().isEmpty()) { + throw new ASException("partnerId不能为空"); + } + if (quantity <= 0) { + throw new ASException("quantity必须大于0"); + } + int purchased = partnerCardPoolService.purchase(partnerId, quantity); + Map data = new HashMap<>(); + data.put("purchased", purchased); + return R.dataSuccess(data); + } + + /** + * 库存查询 + */ + @ApiOperation("库存查询") + @GetMapping("/stock") + public R stock(@RequestParam("partnerId") String partnerId) { + int count = partnerCardPoolService.getAvailableCount(partnerId); + Map data = new HashMap<>(); + data.put("available", count); + return R.dataSuccess(data); + } + + /** + * 分页查询池子记录 + */ + @ApiOperation("分页查询池子记录") + @GetMapping("/list") + public R list(@RequestParam Map params) { + PageUtils page = partnerCardPoolService.queryPage(params); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + /** + * 回退天卡:把partner池子里未消耗(AVAILABLE)的天卡回退到平台天卡池 + */ + @ApiOperation("回退天卡") + @AuditAction(value = "PARTNER_CARD_REVERT", description = "回退天卡到平台池子") + @PostMapping("/revert") + public R revert(@RequestParam("partnerId") String partnerId, + @RequestParam("quantity") int quantity) { + if (partnerId == null || partnerId.trim().isEmpty()) { + throw new ASException("partnerId不能为空"); + } + if (quantity <= 0) { + throw new ASException("回退数量必须大于0"); + } + int reverted = partnerCardPoolService.revert(partnerId, quantity); + Map data = new HashMap<>(); + data.put("reverted", reverted); + return R.dataSuccess(data); + } +} diff --git a/src/main/java/io/as/modules/openapi/dao/ContentPackageDao.java b/src/main/java/io/as/modules/openapi/dao/ContentPackageDao.java new file mode 100644 index 0000000..557cbab --- /dev/null +++ b/src/main/java/io/as/modules/openapi/dao/ContentPackageDao.java @@ -0,0 +1,12 @@ +package io.as.modules.openapi.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.openapi.entity.ContentPackageEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 开放API内容包授权 + */ +@Mapper +public interface ContentPackageDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/openapi/dao/OpenApiCallLogDao.java b/src/main/java/io/as/modules/openapi/dao/OpenApiCallLogDao.java new file mode 100644 index 0000000..09d691a --- /dev/null +++ b/src/main/java/io/as/modules/openapi/dao/OpenApiCallLogDao.java @@ -0,0 +1,12 @@ +package io.as.modules.openapi.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.openapi.entity.OpenApiCallLogEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 开放API调用日志 + */ +@Mapper +public interface OpenApiCallLogDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/openapi/dao/OpenApiPartnerDao.java b/src/main/java/io/as/modules/openapi/dao/OpenApiPartnerDao.java new file mode 100644 index 0000000..0bfcc55 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/dao/OpenApiPartnerDao.java @@ -0,0 +1,12 @@ +package io.as.modules.openapi.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.openapi.entity.OpenApiPartnerEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 开放API合作伙伴 + */ +@Mapper +public interface OpenApiPartnerDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/openapi/dao/PartnerCardPoolDao.java b/src/main/java/io/as/modules/openapi/dao/PartnerCardPoolDao.java new file mode 100644 index 0000000..644f7a1 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/dao/PartnerCardPoolDao.java @@ -0,0 +1,12 @@ +package io.as.modules.openapi.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.openapi.entity.PartnerCardPoolEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 合作伙伴天卡池 + */ +@Mapper +public interface PartnerCardPoolDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/openapi/entity/ContentPackageEntity.java b/src/main/java/io/as/modules/openapi/entity/ContentPackageEntity.java new file mode 100644 index 0000000..487c227 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/entity/ContentPackageEntity.java @@ -0,0 +1,37 @@ +package io.as.modules.openapi.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 开放API内容包授权表 + */ +@Data +@TableName("open_api_content_package") +public class ContentPackageEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 合作伙伴ID */ + private String partnerId; + /** 内容包名称 */ + private String name; + /** 目录ID集合 */ + private String catalogIds; + /** 内容编码集合 */ + private String contentCodes; + /** 状态:ACTIVE / DISABLED */ + private String status; + /** 删除标记 */ + private String deleteFlag; + /** 创建时间 */ + private Date createdAt; + /** 更新时间 */ + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/openapi/entity/OpenApiCallLogEntity.java b/src/main/java/io/as/modules/openapi/entity/OpenApiCallLogEntity.java new file mode 100644 index 0000000..1296f1b --- /dev/null +++ b/src/main/java/io/as/modules/openapi/entity/OpenApiCallLogEntity.java @@ -0,0 +1,39 @@ +package io.as.modules.openapi.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 开放API调用日志表 + */ +@Data +@TableName("open_api_call_log") +public class OpenApiCallLogEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 合作伙伴ID */ + private String partnerId; + /** 应用Key */ + private String appKey; + /** 调用API */ + private String api; + /** HTTP方法 */ + private String httpMethod; + /** 请求参数 */ + private String params; + /** 结果码 */ + private Integer resultCode; + /** 调用方IP */ + private String ip; + /** 耗时(毫秒) */ + private Integer costMs; + /** 创建时间 */ + private Date createdAt; +} diff --git a/src/main/java/io/as/modules/openapi/entity/OpenApiPartnerEntity.java b/src/main/java/io/as/modules/openapi/entity/OpenApiPartnerEntity.java new file mode 100644 index 0000000..6057f46 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/entity/OpenApiPartnerEntity.java @@ -0,0 +1,41 @@ +package io.as.modules.openapi.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 开放API合作伙伴表 + */ +@Data +@TableName("open_api_partner") +public class OpenApiPartnerEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 合作伙伴ID */ + private String partnerId; + /** 应用Key */ + private String appKey; + /** 应用密钥 */ + private String appSecret; + /** 合作伙伴名称 */ + private String name; + /** 状态:ACTIVE / DISABLED */ + private String status; + /** API权限(JSON) */ + private String apiPermissions; + /** 速率限制(默认100) */ + private Integer rateLimit; + /** 删除标记 */ + private String deleteFlag; + /** 创建时间 */ + private Date createdAt; + /** 更新时间 */ + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/openapi/entity/PartnerCardPoolEntity.java b/src/main/java/io/as/modules/openapi/entity/PartnerCardPoolEntity.java new file mode 100644 index 0000000..ada03b1 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/entity/PartnerCardPoolEntity.java @@ -0,0 +1,49 @@ +package io.as.modules.openapi.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 合作伙伴天卡池 + */ +@Data +@TableName("open_api_partner_card_pool") +public class PartnerCardPoolEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 合作伙伴ID */ + private String partnerId; + /** 天卡池ID */ + private Long poolId; + /** 卡编码 */ + private String cardCode; + /** 第三方卡号 */ + private String thirdCardNo; + /** 状态:AVAILABLE / CONSUMED */ + private String status; + /** 设备ID */ + private Long deviceId; + /** 渠道机构ID */ + private Long channelOrgId; + /** 采购批次 */ + private String purchaseBatch; + /** 消费时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date consumedAt; + /** 删除标记 */ + private String deleteFlag; + /** 创建时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + /** 更新时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/openapi/filter/ApiDeviceSignFilter.java b/src/main/java/io/as/modules/openapi/filter/ApiDeviceSignFilter.java new file mode 100755 index 0000000..2f3370c --- /dev/null +++ b/src/main/java/io/as/modules/openapi/filter/ApiDeviceSignFilter.java @@ -0,0 +1,100 @@ +package io.as.modules.openapi.filter; + +import com.google.gson.Gson; +import io.as.common.utils.HttpContextUtils; +import io.as.common.utils.R; +import io.as.modules.openapi.sign.ApiDeviceSignService; +import io.as.modules.openapi.sign.DeviceSignResult; +import org.apache.commons.lang.StringUtils; +import org.apache.http.HttpStatus; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 设备 sign 校验:除 POST /api/jst/init 外,所有以 /api 开头的请求必须在请求头携带 sign,否则拒绝。 + *

校验通过后将 deviceId 和 DeviceSignResult 放入 request 属性,供后续接口使用。

+ *

对齐生产环境 as-partner-backend 的 ApiDeviceSignFilter

+ */ +public class ApiDeviceSignFilter extends OncePerRequestFilter { + + public static final String REQUEST_ATTR_DEVICE_ID = "deviceId"; + public static final String REQUEST_ATTR_DEVICE_SIGN_RESULT = "deviceSignResult"; + public static final String HEADER_SIGN = "sign"; + + private final ApiDeviceSignService apiDeviceSignService; + + public ApiDeviceSignFilter(ApiDeviceSignService apiDeviceSignService) { + this.apiDeviceSignService = apiDeviceSignService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String path = contextRelativePath(request); + if (path == null || !path.startsWith("/api")) { + filterChain.doFilter(request, response); + return; + } + // init 接口不校验 sign(init 返回 sign) + if ("POST".equalsIgnoreCase(request.getMethod()) + && "/api/jst/init".equals(removeTrailingSlash(path))) { + filterChain.doFilter(request, response); + return; + } + // 微信支付回调由微信服务器直接调用,无 sign + if (path.startsWith("/api/payment/")) { + filterChain.doFilter(request, response); + return; + } + String sign = request.getHeader(HEADER_SIGN); + if (StringUtils.isBlank(sign)) { + writeUnauthorized(response, "缺少 sign"); + return; + } + DeviceSignResult result = apiDeviceSignService.verify(sign); + if (result == null) { + writeUnauthorized(response, "sign 无效或已过期"); + return; + } + if (result.getDeviceId() == null) { + writeUnauthorized(response, "设备标识无效"); + return; + } + request.setAttribute(REQUEST_ATTR_DEVICE_ID, result.getDeviceId()); + request.setAttribute(REQUEST_ATTR_DEVICE_SIGN_RESULT, result); + filterChain.doFilter(request, response); + } + + /** 去掉 context-path,与 Filter 匹配的 /api/** 语义一致 */ + private static String contextRelativePath(HttpServletRequest request) { + String uri = request.getRequestURI(); + if (uri == null) { + return null; + } + String ctx = request.getContextPath(); + if (ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)) { + uri = uri.substring(ctx.length()); + } + return uri.isEmpty() ? "/" : uri; + } + + private static String removeTrailingSlash(String path) { + if (path == null || path.length() <= 1) { + return path; + } + return path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + } + + private void writeUnauthorized(HttpServletResponse response, String msg) throws IOException { + response.setStatus(HttpStatus.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.setHeader("Access-Control-Allow-Credentials", "true"); + response.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin()); + response.getWriter().print(new Gson().toJson(R.error(HttpStatus.SC_UNAUTHORIZED, msg))); + } +} diff --git a/src/main/java/io/as/modules/openapi/filter/ApiJstCallLogFilter.java b/src/main/java/io/as/modules/openapi/filter/ApiJstCallLogFilter.java new file mode 100755 index 0000000..8b13789 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/filter/ApiJstCallLogFilter.java @@ -0,0 +1 @@ + diff --git a/src/main/java/io/as/modules/openapi/filter/ByteArrayReplayingHttpServletRequest.java b/src/main/java/io/as/modules/openapi/filter/ByteArrayReplayingHttpServletRequest.java new file mode 100755 index 0000000..8b13789 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/filter/ByteArrayReplayingHttpServletRequest.java @@ -0,0 +1 @@ + diff --git a/src/main/java/io/as/modules/openapi/filter/PartnerSignFilter.java b/src/main/java/io/as/modules/openapi/filter/PartnerSignFilter.java new file mode 100644 index 0000000..523be18 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/filter/PartnerSignFilter.java @@ -0,0 +1,132 @@ +package io.as.modules.openapi.filter; + +import com.google.gson.Gson; +import io.as.common.utils.HttpContextUtils; +import io.as.common.utils.R; +import io.as.common.utils.RedisUtils; +import io.as.modules.openapi.entity.OpenApiPartnerEntity; +import io.as.modules.openapi.service.OpenApiPartnerService; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang.StringUtils; +import org.apache.http.HttpStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 合作伙伴 sign 校验过滤器 + *

拦截 /openapi/partner/** 请求,校验请求头 appKey/timestamp/nonce/sign。

+ *

签名算法:MD5(appKey + timestamp + nonce + appSecret).toUpperCase()

+ *

防重放:timestamp 5分钟内有效 + nonce 5分钟内不可重复(Redis)

+ *

校验通过后将 partnerId 放入 request 属性,供后续 Controller 使用。

+ */ +public class PartnerSignFilter extends OncePerRequestFilter { + + private static final Logger logger = LoggerFactory.getLogger(PartnerSignFilter.class); + + public static final String REQUEST_ATTR_PARTNER_ID = "partnerId"; + public static final String REQUEST_ATTR_APP_KEY = "appKey"; + public static final String HEADER_APP_KEY = "appKey"; + public static final String HEADER_TIMESTAMP = "timestamp"; + public static final String HEADER_NONCE = "nonce"; + public static final String HEADER_SIGN = "sign"; + + /** 时间戳有效期 5 分钟 */ + private static final long TIMESTAMP_VALID_MS = 5 * 60 * 1000L; + /** nonce 缓存 key 前缀,与 daycard/openapi 隔离 */ + private static final String NONCE_KEY_PREFIX = "openapi:partner:nonce:"; + + private final OpenApiPartnerService openApiPartnerService; + private final RedisUtils redisUtils; + + private static final Gson GSON = new Gson(); + + public PartnerSignFilter(OpenApiPartnerService openApiPartnerService, RedisUtils redisUtils) { + this.openApiPartnerService = openApiPartnerService; + this.redisUtils = redisUtils; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String path = request.getRequestURI(); + String ctx = request.getContextPath(); + if (ctx != null && !ctx.isEmpty() && path.startsWith(ctx)) { + path = path.substring(ctx.length()); + } + // 只拦截第三方接口路径前缀(排除 /openapi/partner/list|info|save|update|delete 等管理后台端点) + if (path == null || (!path.startsWith("/openapi/partner/media/") + && !path.startsWith("/openapi/partner/card/") + && !path.startsWith("/openapi/partner/device/") + && !path.startsWith("/openapi/partner/order/"))) { + filterChain.doFilter(request, response); + return; + } + + String appKey = request.getHeader(HEADER_APP_KEY); + String timestamp = request.getHeader(HEADER_TIMESTAMP); + String nonce = request.getHeader(HEADER_NONCE); + String sign = request.getHeader(HEADER_SIGN); + + if (StringUtils.isBlank(appKey) || StringUtils.isBlank(timestamp) + || StringUtils.isBlank(nonce) || StringUtils.isBlank(sign)) { + writeUnauthorized(response, "缺少鉴权参数(appKey/timestamp/nonce/sign)"); + return; + } + + // 1. 校验时间戳有效期 + long ts; + try { + ts = Long.parseLong(timestamp); + } catch (NumberFormatException e) { + writeUnauthorized(response, "timestamp格式无效"); + return; + } + if (Math.abs(System.currentTimeMillis() - ts) > TIMESTAMP_VALID_MS) { + writeUnauthorized(response, "请求已过期"); + return; + } + + // 2. 查合作伙伴 + OpenApiPartnerEntity partner = openApiPartnerService.getByAppKey(appKey); + if (partner == null || !"ACTIVE".equals(partner.getStatus())) { + writeUnauthorized(response, "appKey无效或已禁用"); + return; + } + + // 3. 校验签名: MD5(appKey + timestamp + nonce + appSecret).toUpperCase() + String expectedSign = DigestUtils.md5Hex(appKey + timestamp + nonce + partner.getAppSecret()).toUpperCase(); + if (!expectedSign.equals(sign)) { + writeUnauthorized(response, "签名校验失败"); + return; + } + + // 4. nonce 防重放(原子操作:setIfAbsent 成功=首次,失败=重复) + String nonceKey = NONCE_KEY_PREFIX + nonce; + boolean isNew = redisUtils.setIfAbsent(nonceKey, "1", TIMESTAMP_VALID_MS / 1000); + if (!isNew) { + writeUnauthorized(response, "请求不可重复提交"); + return; + } + + // 5. 通过,放入 partnerId + request.setAttribute(REQUEST_ATTR_PARTNER_ID, partner.getPartnerId()); + request.setAttribute(REQUEST_ATTR_APP_KEY, appKey); + filterChain.doFilter(request, response); + } + + private void writeUnauthorized(HttpServletResponse response, String msg) throws IOException { + logger.warn("合作伙伴鉴权失败: {}", msg); + response.setStatus(HttpStatus.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.setHeader("Access-Control-Allow-Credentials", "true"); + response.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin()); + response.getWriter().print(GSON.toJson(R.error(HttpStatus.SC_UNAUTHORIZED, msg))); + } +} diff --git a/src/main/java/io/as/modules/openapi/form/DeviceInitRequest.java b/src/main/java/io/as/modules/openapi/form/DeviceInitRequest.java new file mode 100644 index 0000000..3da7229 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/form/DeviceInitRequest.java @@ -0,0 +1,53 @@ +package io.as.modules.openapi.form; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 设备初始化请求 + *

App 端只需传激活码 + 设备标识,后端通过激活码解析门店与渠道

+ */ +@Data +public class DeviceInitRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** 激活码(必填,管理后台按门店生成) */ + private String activationCode; + + /** 设备MAC */ + private String mac; + + /** WiFi MAC */ + private String wifiMac; + + /** 设备SN */ + private String deviceSn; + + /** 设备UID */ + private String deviceUid; + + /** Android ID(Settings.Secure.ANDROID_ID,重装不变) */ + private String androidId; + /** 设备厂商 Build.MANUFACTURER */ + private String manufacturer; + /** 设备型号 Build.MODEL */ + private String model; + /** 设备品牌 Build.BRAND */ + private String brand; + + /** 版本号 */ + private String verCode; + + /** 版本名 */ + private String verName; + + /** 安卓版本 */ + private String androidVersion; + + /** 设备型号 */ + private String deviceModel; + + /** 设备名称 */ + private String deviceName; +} diff --git a/src/main/java/io/as/modules/openapi/service/ContentPackageService.java b/src/main/java/io/as/modules/openapi/service/ContentPackageService.java new file mode 100644 index 0000000..a0a796f --- /dev/null +++ b/src/main/java/io/as/modules/openapi/service/ContentPackageService.java @@ -0,0 +1,36 @@ +package io.as.modules.openapi.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.openapi.entity.ContentPackageEntity; + +import java.util.Map; + +/** + * 开放API内容包授权 + */ +public interface ContentPackageService extends IService { + + /** + * 分页查询内容包列表 + * + * @param params 查询参数 + * @return 分页结果 + */ + PageUtils queryPage(Map params); + + /** + * 根据合作伙伴ID获取生效的内容包 + * + * @param partnerId 合作伙伴ID + * @return 内容包实体,不存在返回 null + */ + ContentPackageEntity getByPartnerId(String partnerId); + + /** + * 保存或更新内容包(按 partnerId 判断) + * + * @param entity 内容包实体 + */ + void saveOrUpdatePackage(ContentPackageEntity entity); +} diff --git a/src/main/java/io/as/modules/openapi/service/OpenApiCallLogService.java b/src/main/java/io/as/modules/openapi/service/OpenApiCallLogService.java new file mode 100644 index 0000000..9f9c214 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/service/OpenApiCallLogService.java @@ -0,0 +1,35 @@ +package io.as.modules.openapi.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.openapi.entity.OpenApiCallLogEntity; + +import java.util.Map; + +/** + * 开放API调用日志 + */ +public interface OpenApiCallLogService extends IService { + + /** + * 分页查询调用日志 + * + * @param params 查询参数 + * @return 分页结果 + */ + PageUtils queryPage(Map params); + + /** + * 保存调用日志 + * + * @param partnerId 合作伙伴ID + * @param appKey 应用Key + * @param api 调用API + * @param method HTTP方法 + * @param params 请求参数 + * @param resultCode 结果码 + * @param ip 调用方IP + * @param costMs 耗时(毫秒) + */ + void saveLog(String partnerId, String appKey, String api, String method, String params, Integer resultCode, String ip, Integer costMs); +} diff --git a/src/main/java/io/as/modules/openapi/service/OpenApiPartnerService.java b/src/main/java/io/as/modules/openapi/service/OpenApiPartnerService.java new file mode 100644 index 0000000..3f18d9f --- /dev/null +++ b/src/main/java/io/as/modules/openapi/service/OpenApiPartnerService.java @@ -0,0 +1,29 @@ +package io.as.modules.openapi.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.openapi.entity.OpenApiPartnerEntity; + +import java.util.Map; + +/** + * 开放API合作伙伴 + */ +public interface OpenApiPartnerService extends IService { + + /** + * 分页查询合作伙伴列表 + * + * @param params 查询参数 + * @return 分页结果 + */ + PageUtils queryPage(Map params); + + /** + * 根据 appKey 获取合作伙伴 + * + * @param appKey 应用Key + * @return 合作伙伴实体,不存在返回 null + */ + OpenApiPartnerEntity getByAppKey(String appKey); +} diff --git a/src/main/java/io/as/modules/openapi/service/PartnerCardPoolService.java b/src/main/java/io/as/modules/openapi/service/PartnerCardPoolService.java new file mode 100644 index 0000000..bb04686 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/service/PartnerCardPoolService.java @@ -0,0 +1,88 @@ +package io.as.modules.openapi.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.openapi.entity.PartnerCardPoolEntity; + +import java.util.List; +import java.util.Map; + +/** + * 合作伙伴天卡池 + */ +public interface PartnerCardPoolService extends IService { + + /** + * 分页查询天卡池列表 + * + * @param params 查询参数 + * @return 分页结果 + */ + PageUtils queryPage(Map params); + + /** + * 查询合作伙伴可用天卡数量 + * + * @param partnerId 合作伙伴ID + * @return 可用天卡数量 + */ + int getAvailableCount(String partnerId); + + /** + * 获取合作伙伴可用天卡列表 + * + * @param partnerId 合作伙伴ID + * @param limit 取卡数量 + * @return 可用天卡列表 + */ + List getAvailableCards(String partnerId, int limit); + + /** + * 根据天卡池ID查询 + * + * @param poolId 天卡池ID + * @return 天卡池实体,不存在返回 null + */ + PartnerCardPoolEntity getByPoolId(Long poolId); + + /** + * 批量保存采购的天卡 + * + * @param partnerId 合作伙伴ID + * @param purchaseBatch 采购批次 + * @param cards 天卡列表 + */ + void saveBatchCards(String partnerId, String purchaseBatch, List cards); + + /** + * 采购天卡:从平台天卡池划拨指定数量到合作伙伴池子 + * + * @param partnerId 合作伙伴ID + * @param quantity 采购数量 + * @return 实际划拨数量 + */ + int purchase(String partnerId, int quantity); + + /** + * 消耗天卡(代销模式):从合作伙伴池子分配1张天卡,绑定到设备 + *

APK后续用同一设备标识调 /api/jst/init(免activationCode)+ checkBenefits 拿天卡播放

+ * + * @param partnerId 合作伙伴ID(由PartnerSignFilter注入) + * @param mac 设备MAC(与wifiMac/deviceUid/deviceSn至少传一个) + * @param wifiMac WiFi MAC + * @param deviceUid 设备唯一标识 + * @param deviceSn 设备序列号 + * @param channelOrgId 渠道组织ID(第三方直接传递渠道身份) + * @return 天卡信息(deviceId/thirdCardNo/expireAt/status) + */ + Map consume(String partnerId, String mac, String wifiMac, String deviceUid, String deviceSn, Long channelOrgId); + + /** + * 回退天卡:把partner池子里未消耗(AVAILABLE)的天卡回退到平台天卡池 + * + * @param partnerId 合作伙伴ID + * @param quantity 回退数量 + * @return 实际回退数量 + */ + int revert(String partnerId, int quantity); +} diff --git a/src/main/java/io/as/modules/openapi/service/impl/ContentPackageServiceImpl.java b/src/main/java/io/as/modules/openapi/service/impl/ContentPackageServiceImpl.java new file mode 100644 index 0000000..0392863 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/service/impl/ContentPackageServiceImpl.java @@ -0,0 +1,65 @@ +package io.as.modules.openapi.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.openapi.dao.ContentPackageDao; +import io.as.modules.openapi.entity.ContentPackageEntity; +import io.as.modules.openapi.service.ContentPackageService; +import org.apache.commons.lang.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.Map; + +/** + * 开放API内容包授权 + */ +@Service("contentPackageService") +public class ContentPackageServiceImpl extends ServiceImpl implements ContentPackageService { + + @Override + public PageUtils queryPage(Map params) { + String name = (String) params.get("name"); + String status = (String) params.get("status"); + + IPage page = this.page( + new Query().getPage(params), + new QueryWrapper() + .like(StringUtils.isNotBlank(name), "name", name) + .eq(StringUtils.isNotBlank(status), "status", status) + .eq("delete_flag", "UNDEL") + .orderByDesc("created_at") + ); + + return new PageUtils(page); + } + + @Override + public ContentPackageEntity getByPartnerId(String partnerId) { + return this.getOne( + new QueryWrapper() + .eq("partner_id", partnerId) + .eq("status", "ACTIVE") + .eq("delete_flag", "UNDEL") + .last("LIMIT 1") + ); + } + + @Override + public void saveOrUpdatePackage(ContentPackageEntity entity) { + ContentPackageEntity exist = this.getOne( + new QueryWrapper() + .eq("partner_id", entity.getPartnerId()) + .eq("delete_flag", "UNDEL") + .last("LIMIT 1") + ); + if (exist != null) { + entity.setId(exist.getId()); + this.updateById(entity); + } else { + this.save(entity); + } + } +} diff --git a/src/main/java/io/as/modules/openapi/service/impl/OpenApiCallLogServiceImpl.java b/src/main/java/io/as/modules/openapi/service/impl/OpenApiCallLogServiceImpl.java new file mode 100644 index 0000000..4835911 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/service/impl/OpenApiCallLogServiceImpl.java @@ -0,0 +1,53 @@ +package io.as.modules.openapi.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.openapi.dao.OpenApiCallLogDao; +import io.as.modules.openapi.entity.OpenApiCallLogEntity; +import io.as.modules.openapi.service.OpenApiCallLogService; +import org.apache.commons.lang.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.Map; + +/** + * 开放API调用日志 + */ +@Service("openApiCallLogService") +public class OpenApiCallLogServiceImpl extends ServiceImpl implements OpenApiCallLogService { + + @Override + public PageUtils queryPage(Map params) { + String partnerId = (String) params.get("partnerId"); + String api = (String) params.get("api"); + + IPage page = this.page( + new Query().getPage(params), + new QueryWrapper() + .eq(StringUtils.isNotBlank(partnerId), "partner_id", partnerId) + .like(StringUtils.isNotBlank(api), "api", api) + .orderByDesc("created_at") + ); + + return new PageUtils(page); + } + + @Override + public void saveLog(String partnerId, String appKey, String api, String method, String params, Integer resultCode, String ip, Integer costMs) { + OpenApiCallLogEntity entity = new OpenApiCallLogEntity(); + entity.setPartnerId(partnerId); + entity.setAppKey(appKey); + entity.setApi(api); + entity.setHttpMethod(method); + entity.setParams(params); + entity.setResultCode(resultCode); + entity.setIp(ip); + entity.setCostMs(costMs); + entity.setCreatedAt(new Date()); + this.save(entity); + } +} diff --git a/src/main/java/io/as/modules/openapi/service/impl/OpenApiPartnerServiceImpl.java b/src/main/java/io/as/modules/openapi/service/impl/OpenApiPartnerServiceImpl.java new file mode 100644 index 0000000..2ae2c6f --- /dev/null +++ b/src/main/java/io/as/modules/openapi/service/impl/OpenApiPartnerServiceImpl.java @@ -0,0 +1,49 @@ +package io.as.modules.openapi.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.openapi.dao.OpenApiPartnerDao; +import io.as.modules.openapi.entity.OpenApiPartnerEntity; +import io.as.modules.openapi.service.OpenApiPartnerService; +import org.apache.commons.lang.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.Map; + +/** + * 开放API合作伙伴 + */ +@Service("openApiPartnerService") +public class OpenApiPartnerServiceImpl extends ServiceImpl implements OpenApiPartnerService { + + @Override + public PageUtils queryPage(Map params) { + String name = (String) params.get("name"); + String partnerId = (String) params.get("partnerId"); + String status = (String) params.get("status"); + + IPage page = this.page( + new Query().getPage(params), + new QueryWrapper() + .like(StringUtils.isNotBlank(name), "name", name) + .like(StringUtils.isNotBlank(partnerId), "partner_id", partnerId) + .eq(StringUtils.isNotBlank(status), "status", status) + .eq("delete_flag", "UNDEL") + .orderByDesc("created_at") + ); + + return new PageUtils(page); + } + + @Override + public OpenApiPartnerEntity getByAppKey(String appKey) { + return this.getOne( + new QueryWrapper() + .eq("app_key", appKey) + .eq("delete_flag", "UNDEL") + ); + } +} diff --git a/src/main/java/io/as/modules/openapi/service/impl/PartnerCardPoolServiceImpl.java b/src/main/java/io/as/modules/openapi/service/impl/PartnerCardPoolServiceImpl.java new file mode 100644 index 0000000..6810aa9 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/service/impl/PartnerCardPoolServiceImpl.java @@ -0,0 +1,442 @@ +package io.as.modules.openapi.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.common.utils.RedisUtils; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.dao.DayCardPoolDao; +import io.as.modules.daycard.entity.DayCardPoolEntity; +import io.as.modules.device.dao.DeviceDao; +import io.as.modules.device.entity.DeviceEntity; +import io.as.modules.device.service.DeviceService; +import io.as.modules.jst.service.JstActivateCardResult; +import io.as.modules.jst.service.JushitingApiService; +import io.as.modules.openapi.dao.PartnerCardPoolDao; +import io.as.modules.openapi.entity.PartnerCardPoolEntity; +import io.as.modules.openapi.service.PartnerCardPoolService; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.task.TaskExecutor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +/** + * 合作伙伴天卡池 + */ +@Service("partnerCardPoolService") +public class PartnerCardPoolServiceImpl extends ServiceImpl implements PartnerCardPoolService { + + private static final Logger logger = LoggerFactory.getLogger(PartnerCardPoolServiceImpl.class); + + @Autowired + private DayCardPoolDao dayCardPoolDao; + @Autowired + private DeviceDao deviceDao; + @Autowired + private DeviceService deviceService; + @Autowired + private JushitingApiService jushitingApiService; + @Autowired + @Qualifier("thirdPartyActivateExecutor") + private TaskExecutor activateExecutor; + @Autowired + private RedisUtils redisUtils; + + @Override + public PageUtils queryPage(Map params) { + String partnerId = (String) params.get("partnerId"); + String status = (String) params.get("status"); + String purchaseBatch = (String) params.get("purchaseBatch"); + + IPage page = this.page( + new Query().getPage(params), + new QueryWrapper() + .eq(StringUtils.isNotBlank(partnerId), "partner_id", partnerId) + .eq(StringUtils.isNotBlank(status), "status", status) + .eq(StringUtils.isNotBlank(purchaseBatch), "purchase_batch", purchaseBatch) + .eq("delete_flag", "UNDEL") + .orderByDesc("created_at") + ); + return new PageUtils(page); + } + + @Override + public int getAvailableCount(String partnerId) { + return Math.toIntExact(this.count( + new QueryWrapper() + .eq("partner_id", partnerId) + .eq("status", "AVAILABLE") + .eq("delete_flag", "UNDEL") + )); + } + + @Override + public List getAvailableCards(String partnerId, int limit) { + return this.list( + new QueryWrapper() + .eq("partner_id", partnerId) + .eq("status", "AVAILABLE") + .eq("delete_flag", "UNDEL") + .orderByAsc("id") + .last("LIMIT " + limit) + ); + } + + @Override + public PartnerCardPoolEntity getByPoolId(Long poolId) { + return this.getOne( + new QueryWrapper() + .eq("pool_id", poolId) + .last("LIMIT 1") + ); + } + + @Override + public void saveBatchCards(String partnerId, String purchaseBatch, List cards) { + this.saveBatch(cards); + } + + // ==================== 采购 ==================== + + @Override + public int purchase(String partnerId, int quantity) { + if (quantity <= 0) { + throw new ASException("采购数量必须大于0", 400); + } + // 1. 从平台天卡池取 quantity 张 UNUSED 的卡(SKIP LOCKED 防阻塞) + List cards = dayCardPoolDao.selectList( + new QueryWrapper() + .eq("status", DayCardConstants.POOL_STATUS_UNUSED) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .last("LIMIT " + quantity + " FOR UPDATE SKIP LOCKED") + ); + if (cards.isEmpty()) { + throw new ASException("平台天卡池库存不足", 400); + } + + String purchaseBatch = "PUR" + System.currentTimeMillis(); + Date now = new Date(); + for (DayCardPoolEntity card : cards) { + // 2. 平台卡状态 UNUSED -> RESERVED(划拨给partner) + int updated = dayCardPoolDao.update(null, new UpdateWrapper() + .eq("id", card.getId()) + .eq("status", DayCardConstants.POOL_STATUS_UNUSED) + .set("status", DayCardConstants.POOL_STATUS_RESERVED) + .set("updated_at", now) + ); + if (updated == 0) { + continue; // 被其他线程抢走,跳过 + } + // 3. 记录到 partner 池子 + PartnerCardPoolEntity pcp = new PartnerCardPoolEntity(); + pcp.setPartnerId(partnerId); + pcp.setPoolId(card.getId()); + pcp.setCardCode(card.getCardCode()); + pcp.setThirdCardNo(card.getThirdCardNo()); + pcp.setStatus("AVAILABLE"); + pcp.setPurchaseBatch(purchaseBatch); + pcp.setDeleteFlag("UNDEL"); + pcp.setCreatedAt(now); + pcp.setUpdatedAt(now); + this.save(pcp); + } + logger.info("合作伙伴采购天卡: partnerId={}, quantity={}, batch={}", partnerId, cards.size(), purchaseBatch); + return cards.size(); + } + + // ==================== 消耗(代销模式) ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public Map consume(String partnerId, String mac, String wifiMac, String deviceUid, String deviceSn, Long channelOrgId) { + if (StringUtils.isBlank(mac) && StringUtils.isBlank(wifiMac) + && StringUtils.isBlank(deviceUid) && StringUtils.isBlank(deviceSn)) { + throw new ASException("mac/wifiMac/deviceUid/deviceSn 至少传一个", 400); + } + + // 1. Redis防重锁:按设备标识加锁,防止并发重复消耗 + String lockKey = DayCardConstants.REDIS_PARTNER_CONSUME_LOCK_PREFIX + + (StringUtils.isNotBlank(mac) ? mac + : StringUtils.isNotBlank(wifiMac) ? wifiMac + : StringUtils.isNotBlank(deviceUid) ? deviceUid : deviceSn); + boolean locked = redisUtils.setIfAbsent(lockKey, "1", 30); + if (!locked) { + throw new ASException("操作太频繁,请稍后再试", 429); + } + + try { + // 2. 查找/创建设备 + DeviceEntity device = findOrCreateDevice(mac, wifiMac, deviceUid, deviceSn); + boolean isNew = device.getId() == null; + Date now = new Date(); + if (isNew) { + if (StringUtils.isNotBlank(mac)) device.setMac(mac); + if (StringUtils.isNotBlank(wifiMac)) device.setWifiMac(wifiMac); + if (StringUtils.isNotBlank(deviceUid)) device.setDeviceUid(deviceUid); + if (StringUtils.isNotBlank(deviceSn)) device.setDeviceSn(deviceSn); + device.setLastHeartbeat(now); + device.setCreatedAt(now); + device.setDeleteFlag("UNDEL"); + device.setStatus("ACTIVE"); + device.setSource("PARTNER"); + device.setPartnerId(partnerId); + deviceService.saveDevice(device); + } + + // 3. 幂等检查:设备已有有效天卡则直接返回(防重复消耗) + DayCardPoolEntity existing = getDeviceActiveCard(device.getId()); + if (existing != null) { + Map idempotent = new HashMap<>(); + idempotent.put("deviceId", device.getId()); + idempotent.put("thirdCardNo", existing.getThirdCardNo()); + idempotent.put("expireAt", existing.getExpireAt()); + idempotent.put("status", "ACTIVE"); + logger.info("代销天卡消耗幂等返回: deviceId={}, poolId={}", device.getId(), existing.getId()); + return idempotent; + } + + // 4. 从 partner 池子取一张可用天卡(FOR UPDATE SKIP LOCKED 防并发超卖) + PartnerCardPoolEntity pcp = this.getOne( + new QueryWrapper() + .eq("partner_id", partnerId) + .eq("status", "AVAILABLE") + .eq("delete_flag", "UNDEL") + .orderByAsc("id") + .last("LIMIT 1 FOR UPDATE SKIP LOCKED") + ); + if (pcp == null) { + throw new ASException("天卡池库存不足", 400); + } + + // 5. 绑定天卡到设备:day_card_pool RESERVED -> USED + deviceId + expireAt(默认24h) + Date defaultExpire = new Date(now.getTime() + 24 * 60 * 60 * 1000L); + int updated = dayCardPoolDao.update(null, new UpdateWrapper() + .eq("id", pcp.getPoolId()) + .eq("status", DayCardConstants.POOL_STATUS_RESERVED) + .set("status", DayCardConstants.POOL_STATUS_USED) + .set("device_id", device.getId()) + .set("activate_status", DayCardConstants.ACTIVATE_STATUS_PENDING) + .set("used_at", now) + .set("expire_at", defaultExpire) + .set("updated_at", now) + ); + if (updated == 0) { + throw new ASException("天卡分配失败,请重试", 500); + } + + // 6. 更新 partner 池子:CONSUMED + deviceId + channelOrgId + pcp.setStatus("CONSUMED"); + pcp.setDeviceId(device.getId()); + pcp.setChannelOrgId(channelOrgId); + pcp.setConsumedAt(now); + pcp.setUpdatedAt(now); + this.updateById(pcp); + + // 7. 异步激活天卡(调橘视厅) + final Long poolId = pcp.getPoolId(); + final Long deviceId = device.getId(); + activateExecutor.execute(() -> activateCardAsync(poolId, deviceId)); + + // 8. 返回天卡信息(异步激活中,客户端通过checkBenefits轮询确认) + // thirdCardNo不暴露给第三方,App通过init+checkBenefits自行获取播放凭证 + Map result = new HashMap<>(); + result.put("deviceId", device.getId()); + result.put("expireAt", defaultExpire); + result.put("status", "ACTIVATING"); + logger.info("代销天卡消耗: partnerId={}, deviceId={}, poolId={}, thirdCardNo={}", + partnerId, device.getId(), poolId, pcp.getThirdCardNo()); + return result; + } finally { + redisUtils.delete(lockKey); + } + } + + // ==================== 回退 ==================== + + @Override + public int revert(String partnerId, int quantity) { + if (quantity <= 0) { + throw new ASException("回退数量必须大于0", 400); + } + List cards = getAvailableCards(partnerId, quantity); + if (cards.isEmpty()) { + return 0; + } + Date now = new Date(); + for (PartnerCardPoolEntity card : cards) { + // 平台天卡池 RESERVED -> UNUSED(恢复可分配) + dayCardPoolDao.update(null, new UpdateWrapper() + .eq("id", card.getPoolId()) + .eq("status", DayCardConstants.POOL_STATUS_RESERVED) + .set("status", DayCardConstants.POOL_STATUS_UNUSED) + .set("updated_at", now) + ); + // partner池子标记DISABLED + card.setStatus("DISABLED"); + card.setUpdatedAt(now); + this.updateById(card); + } + logger.info("合作伙伴回退天卡: partnerId={}, quantity={}", partnerId, cards.size()); + return cards.size(); + } + + /** 按设备标识查找已有设备,找不到返回新实体(标识优先级:mac > wifiMac > deviceUid > deviceSn) */ + private DeviceEntity findOrCreateDevice(String mac, String wifiMac, String deviceUid, String deviceSn) { + DeviceEntity existing = null; + if (StringUtils.isNotBlank(mac)) { + existing = deviceDao.selectOne(new QueryWrapper().eq("mac", mac).eq("delete_flag", "UNDEL").last("LIMIT 1")); + } + if (existing == null && StringUtils.isNotBlank(wifiMac)) { + existing = deviceDao.selectOne(new QueryWrapper().eq("wifi_mac", wifiMac).eq("delete_flag", "UNDEL").last("LIMIT 1")); + } + if (existing == null && StringUtils.isNotBlank(deviceUid)) { + existing = deviceDao.selectOne(new QueryWrapper().eq("device_uid", deviceUid).eq("delete_flag", "UNDEL").last("LIMIT 1")); + } + if (existing == null && StringUtils.isNotBlank(deviceSn)) { + existing = deviceDao.selectOne(new QueryWrapper().eq("device_sn", deviceSn).eq("delete_flag", "UNDEL").last("LIMIT 1")); + } + return existing != null ? existing : new DeviceEntity(); + } + + /** 查询设备当前有效的天卡(status=USED 且未过期) */ + private DayCardPoolEntity getDeviceActiveCard(Long deviceId) { + return dayCardPoolDao.selectOne(new QueryWrapper() + .eq("device_id", deviceId) + .eq("status", DayCardConstants.POOL_STATUS_USED) + .eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL) + .isNotNull("expire_at") + .gt("expire_at", new Date()) + .orderByDesc("used_at") + .last("LIMIT 1")); + } + + /** 异步激活天卡:调橘视厅 getToken + activateCard,更新真实过期时间 */ + private void activateCardAsync(Long poolId, Long deviceId) { + // 激活锁:防止同一张卡重复激活(参考生产项目 api:thirdparty:activate:lock:{cardId}) + String lockKey = DayCardConstants.REDIS_THIRD_PARTY_ACTIVATE_LOCK_PREFIX + poolId; + boolean locked = redisUtils.setIfAbsent(lockKey, "1", 3600); + if (!locked) { + logger.info("天卡激活任务已存在,跳过: poolId={}", poolId); + return; + } + try { + DayCardPoolEntity pool = dayCardPoolDao.selectById(poolId); + if (pool == null) { + logger.warn("异步激活失败:天卡不存在, poolId={}", poolId); + return; + } + DeviceEntity device = deviceDao.selectById(deviceId); + if (device == null) { + updateActivateStatus(poolId, DayCardConstants.ACTIVATE_STATUS_FAILED, "设备不存在: " + deviceId, null); + return; + } + String thirdCardRef = pool.getThirdCardNo(); + if (StringUtils.isBlank(thirdCardRef)) { + updateActivateStatus(poolId, DayCardConstants.ACTIVATE_STATUS_FAILED, "第三方卡号为空", null); + return; + } + + // 1. 免密登录获取Token(优先从缓存读取,参考生产项目) + String token; + String memberCode; + String tokenCacheKey = DayCardConstants.REDIS_THIRD_PARTY_TOKEN_PREFIX + thirdCardRef; + @SuppressWarnings("unchecked") + Map cached = redisUtils.get(tokenCacheKey, Map.class); + if (cached != null && StringUtils.isNotBlank(cached.get("token")) + && StringUtils.isNotBlank(cached.get("memberCode"))) { + token = cached.get("token"); + memberCode = cached.get("memberCode"); + } else { + Map tokenResult = jushitingApiService.getToken(device, thirdCardRef); + if (tokenResult == null || StringUtils.isBlank(tokenResult.get("token"))) { + updateActivateStatus(poolId, DayCardConstants.ACTIVATE_STATUS_FAILED, "获取Token失败", null); + return; + } + token = tokenResult.get("token"); + memberCode = tokenResult.get("memberCode"); + // 缓存token和memberCode,5分钟有效 + Map cacheData = new HashMap<>(); + cacheData.put("token", token); + cacheData.put("memberCode", memberCode); + redisUtils.set(tokenCacheKey, cacheData, 5 * 60); + } + + // 2. 卡密激活 + JstActivateCardResult activateResult = jushitingApiService.activateCard( + deviceId, pool.getCardCode(), memberCode, token); + if (!activateResult.isSuccess()) { + String errMsg = activateResult.getErrorMessage() != null ? activateResult.getErrorMessage() : "卡密激活返回失败"; + updateActivateStatus(poolId, DayCardConstants.ACTIVATE_STATUS_FAILED, errMsg, null); + return; + } + // 3. 确定过期时间 + Date expireAt = null; + if (StringUtils.isNotBlank(activateResult.getExpireTime())) { + expireAt = parseExpireTime(activateResult.getExpireTime()); + } + if (expireAt == null) { + String vipExpire = jushitingApiService.queryVipExpireTime(deviceId, thirdCardRef); + if (StringUtils.isNotBlank(vipExpire)) { + expireAt = parseExpireTime(vipExpire); + } + } + if (expireAt == null) { + expireAt = new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000L); + } + // 4. 更新过期时间 + 激活成功 + updateActivateStatus(poolId, DayCardConstants.ACTIVATE_STATUS_SUCCESS, null, expireAt); + logger.info("代销天卡激活完成: poolId={}, deviceId={}, expireAt={}", poolId, deviceId, expireAt); + } catch (Exception e) { + logger.error("代销天卡激活异常: poolId={}", poolId, e); + updateActivateStatus(poolId, DayCardConstants.ACTIVATE_STATUS_FAILED, e.getMessage(), null); + } finally { + try { + redisUtils.delete(lockKey); + } catch (Exception e) { + logger.warn("释放激活锁失败: key={}", lockKey, e); + } + } + } + + private void updateActivateStatus(Long poolId, String status, String error, Date expireAt) { + UpdateWrapper uw = new UpdateWrapper<>(); + uw.eq("id", poolId) + .set("activate_status", status) + .set("activate_error", error) + .set("updated_at", new Date()); + if (expireAt != null) { + uw.set("expire_at", expireAt).set("activated_at", new Date()); + } + dayCardPoolDao.update(null, uw); + } + + private static Date parseExpireTime(String time) { + if (time == null || time.isEmpty()) { + return null; + } + java.util.TimeZone tz = java.util.TimeZone.getTimeZone("Asia/Shanghai"); + try { + java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + sdf.setTimeZone(tz); + return sdf.parse(time); + } catch (Exception e) { + try { + java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd"); + sdf.setTimeZone(tz); + return sdf.parse(time); + } catch (Exception ignored) { + return null; + } + } + } +} diff --git a/src/main/java/io/as/modules/openapi/sign/ApiDeviceSignService.java b/src/main/java/io/as/modules/openapi/sign/ApiDeviceSignService.java new file mode 100755 index 0000000..55d2d68 --- /dev/null +++ b/src/main/java/io/as/modules/openapi/sign/ApiDeviceSignService.java @@ -0,0 +1,41 @@ +package io.as.modules.openapi.sign; + +/** + * 设备 sign 服务:init 成功后返回的加密签名(设备ID+毫秒时间戳),供后续接口校验 + */ +public interface ApiDeviceSignService { + + /** + * 生成 sign:加密(设备ID + 毫秒时间戳),并保存 deviceId 与 orgCode 的关系 + * + * @param deviceId 设备ID + * @param timestampMs 毫秒时间戳 + * @param orgCode 组织编码(渠道激活前缀) + * @return 签名字符串,用于响应与请求头 + */ + String generate(Long deviceId, long timestampMs, String orgCode); + + /** + * 校验 sign,解析出设备ID与时间戳 + * + * @param sign 请求头中的 sign + * @return 解析结果,无效或过期返回 null + */ + DeviceSignResult verify(String sign); + + /** + * 刷新 sign 过期时间:心跳等场景下,将 Redis 中该设备 sign 缓存的 TTL 重新延长 + * + * @param deviceId 设备ID + */ + void refreshExpire(Long deviceId); + + /** + * 作废设备当前 sign(设备解绑/逆向 init 时调用) + *

用哨兵缓存覆盖 Redis 中该设备的最新 sign,校验时旧 sign 因不匹配而被拒绝, + * 直到重新 init 调 generate 覆盖或 TTL 过期。App 端旧 sign 失效后无法进入首页,需重新输入激活码。

+ * + * @param deviceId 设备ID + */ + void invalidate(Long deviceId); +} diff --git a/src/main/java/io/as/modules/openapi/sign/DeviceSignCache.java b/src/main/java/io/as/modules/openapi/sign/DeviceSignCache.java new file mode 100755 index 0000000..668c12a --- /dev/null +++ b/src/main/java/io/as/modules/openapi/sign/DeviceSignCache.java @@ -0,0 +1,22 @@ +package io.as.modules.openapi.sign; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 设备 sign 在 Redis 中的缓存结构:保存 deviceId、orgCode、sign 以及时间戳 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DeviceSignCache implements Serializable { + private static final long serialVersionUID = 1L; + + private Long deviceId; + private String orgCode; + private String sign; + private long timestamp; +} diff --git a/src/main/java/io/as/modules/openapi/sign/DeviceSignResult.java b/src/main/java/io/as/modules/openapi/sign/DeviceSignResult.java new file mode 100755 index 0000000..760f15c --- /dev/null +++ b/src/main/java/io/as/modules/openapi/sign/DeviceSignResult.java @@ -0,0 +1,18 @@ +package io.as.modules.openapi.sign; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 设备 sign 校验结果:解析出的设备ID、时间戳及 orgCode 前缀 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DeviceSignResult { + private Long deviceId; + private long timestamp; + /** 生成 sign 时携带的 orgCode(渠道前缀) */ + private String orgCode; +} diff --git a/src/main/java/io/as/modules/openapi/sign/impl/ApiDeviceSignServiceImpl.java b/src/main/java/io/as/modules/openapi/sign/impl/ApiDeviceSignServiceImpl.java new file mode 100755 index 0000000..192024a --- /dev/null +++ b/src/main/java/io/as/modules/openapi/sign/impl/ApiDeviceSignServiceImpl.java @@ -0,0 +1,138 @@ +package io.as.modules.openapi.sign.impl; + +import io.as.common.utils.RedisUtils; +import io.as.modules.openapi.sign.ApiDeviceSignService; +import io.as.modules.openapi.sign.DeviceSignCache; +import io.as.modules.openapi.sign.DeviceSignResult; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * 设备 sign:HMAC-SHA256(deviceId:timestamp:orgCode, secret),Base64URL 输出;校验时验证签名并检查时间戳在有效期内 + *

对齐生产环境 as-partner-backend 的 ApiDeviceSignServiceImpl

+ */ +@Service +public class ApiDeviceSignServiceImpl implements ApiDeviceSignService { + + private static final String HMAC_SHA256 = "HmacSHA256"; + /** sign 有效时长(毫秒):24 小时 */ + private static final long SIGN_VALID_MS = 24 * 60 * 60 * 1000; + /** Redis key 前缀:openapi: 隔离于生产 api:jst: */ + private static final String REDIS_KEY_LATEST_SIGN_PREFIX = "openapi:device-sign:"; + + @Value("${renren.api.device-sign-secret:renren-device-sign-secret-change-in-prod}") + private String secret; + + @Autowired + private RedisUtils redisUtils; + + @Override + public String generate(Long deviceId, long timestampMs, String orgCode) { + String orgCodePart = orgCode == null ? "" : orgCode.trim(); + String payload = deviceId + ":" + timestampMs + ":" + orgCodePart; + String hmac = hmacSha256Hex(payload, secret); + String toEncode = payload + ":" + hmac; + String sign = Base64.getUrlEncoder().withoutPadding().encodeToString(toEncode.getBytes(StandardCharsets.UTF_8)); + if (deviceId != null) { + String key = REDIS_KEY_LATEST_SIGN_PREFIX + deviceId; + DeviceSignCache cache = new DeviceSignCache(deviceId, orgCodePart, sign, timestampMs); + redisUtils.set(key, cache, SIGN_VALID_MS / 1000); + } + return sign; + } + + @Override + public DeviceSignResult verify(String sign) { + if (StringUtils.isBlank(sign)) { + return null; + } + try { + String decoded = new String(Base64.getUrlDecoder().decode(sign.trim()), StandardCharsets.UTF_8); + int lastColon = decoded.lastIndexOf(':'); + if (lastColon <= 0) { + return null; + } + String payload = decoded.substring(0, lastColon); + String hmacReceived = decoded.substring(lastColon + 1); + String hmacExpected = hmacSha256Hex(payload, secret); + if (!hmacExpected.equalsIgnoreCase(hmacReceived)) { + return null; + } + // payload: deviceId:timestamp:orgCode + String[] parts = payload.split(":", 3); + if (parts.length < 2) { + return null; + } + Long deviceId = Long.parseLong(parts[0].trim()); + long timestamp = Long.parseLong(parts[1].trim()); + String orgCode = parts.length >= 3 ? parts[2].trim() : null; + long now = System.currentTimeMillis(); + if (timestamp > now || (now - timestamp) > SIGN_VALID_MS) { + return null; + } + + if (deviceId == null) { + return null; + } + // 结合 Redis 校验:只允许当前设备最新 sign 生效(若 Redis 有记录且不同,则判为旧 sign) + String key = REDIS_KEY_LATEST_SIGN_PREFIX + deviceId; + DeviceSignCache cache = redisUtils.get(key, DeviceSignCache.class); + if (cache != null && !sign.equals(cache.getSign())) { + return null; + } + // Redis 没记录时,用签名中自带的 orgCode;有记录时以缓存中的为准 + if (cache != null && cache.getOrgCode() != null) { + orgCode = cache.getOrgCode(); + } + return new DeviceSignResult(deviceId, timestamp, orgCode); + } catch (Exception e) { + return null; + } + } + + @Override + public void refreshExpire(Long deviceId) { + if (deviceId == null) { + return; + } + String key = REDIS_KEY_LATEST_SIGN_PREFIX + deviceId; + DeviceSignCache cache = redisUtils.get(key, DeviceSignCache.class); + if (cache != null) { + redisUtils.set(key, cache, SIGN_VALID_MS / 1000); + } + } + + @Override + public void invalidate(Long deviceId) { + if (deviceId == null) { + return; + } + String key = REDIS_KEY_LATEST_SIGN_PREFIX + deviceId; + // 哨兵缓存:verify 时 cache != null 且 sign != "REVOKED" 即拒绝旧 sign, + // 直到重新 init 调 generate 覆盖此哨兵或 TTL 过期。 + DeviceSignCache revoked = new DeviceSignCache(deviceId, null, "REVOKED", System.currentTimeMillis()); + redisUtils.set(key, revoked, SIGN_VALID_MS / 1000); + } + + private static String hmacSha256Hex(String data, String secretKey) { + try { + Mac mac = Mac.getInstance(HMAC_SHA256); + mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), HMAC_SHA256)); + byte[] hmac = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(hmac.length * 2); + for (byte b : hmac) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception e) { + throw new RuntimeException("HMAC error", e); + } + } +} diff --git a/src/main/java/io/as/modules/payment/config/WechatPayConfig.java b/src/main/java/io/as/modules/payment/config/WechatPayConfig.java new file mode 100644 index 0000000..8939a96 --- /dev/null +++ b/src/main/java/io/as/modules/payment/config/WechatPayConfig.java @@ -0,0 +1,27 @@ +package io.as.modules.payment.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 微信支付配置(对应 application.yml 中 renren.wechat.pay 前缀) + */ +@Data +@Component +@ConfigurationProperties(prefix = "renren.wechat.pay") +public class WechatPayConfig { + + /** 微信应用 appId */ + private String appId; + /** 商户号 */ + private String mchId; + /** API v3 密钥 */ + private String apiV3Key; + /** 商户证书序列号 */ + private String certSerialNo; + /** 商户私钥文件路径(apiclient_key.pem) */ + private String privateKeyPath; + /** 支付回调通知地址 */ + private String notifyUrl; +} diff --git a/src/main/java/io/as/modules/payment/controller/PaymentController.java b/src/main/java/io/as/modules/payment/controller/PaymentController.java new file mode 100644 index 0000000..1858cf2 --- /dev/null +++ b/src/main/java/io/as/modules/payment/controller/PaymentController.java @@ -0,0 +1,283 @@ +package io.as.modules.payment.controller; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import io.as.common.annotation.AuditAction; +import io.as.common.exception.ASException; +import io.as.common.utils.R; +import io.as.common.validator.ValidatorUtils; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.entity.DayCardOrderEntity; +import io.as.modules.daycard.entity.DayCardRefundEntity; +import io.as.modules.daycard.service.DayCardOrderService; +import io.as.modules.daycard.service.DayCardRefundService; +import io.as.modules.payment.form.PaymentCreateRequest; +import io.as.modules.payment.service.WechatPayResult; +import io.as.modules.payment.service.WechatPayService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; +import java.io.BufferedReader; +import java.math.BigDecimal; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +/** + * 支付 Controller + * + * 注意:/openapi/payment/notify 为微信支付回调地址,无需签名鉴权。 + * 当前 ApiDeviceSignFilter 仅拦截 /api/* 路径,/openapi/** 路径不在签名校验范围内。 + */ +@Api(tags = "支付") +@RestController +@RequestMapping("/openapi/payment") +public class PaymentController { + + private static final Logger logger = LoggerFactory.getLogger(PaymentController.class); + + @Autowired + private WechatPayService wechatPayService; + + @Autowired + private DayCardOrderService dayCardOrderService; + + @Autowired + private DayCardRefundService dayCardRefundService; + + /** + * 创建微信 Native 支付订单 + *

从订单号查询订单金额(而非客户端传入金额),调用微信支付下单后将 code_url 写入订单

+ * 返回 code_url(二维码链接),前端据此生成二维码供用户扫码支付 + */ + @ApiOperation("创建微信Native支付订单") + @AuditAction(value = "PAYMENT_NATIVE_CREATE", description = "创建微信支付订单") + @PostMapping("/native/create") + public R createNativePay(@RequestBody PaymentCreateRequest request) { + ValidatorUtils.validateEntity(request); + + // 从订单号查询订单,使用订单金额而非客户端传入金额 + DayCardOrderEntity order = dayCardOrderService.getByOrderNo(request.getOrderNo()); + if (order == null) { + throw new ASException("订单不存在: " + request.getOrderNo()); + } + + String codeUrl = wechatPayService.createNativePay( + order.getOrderNo(), request.getDescription(), order.getAmount()); + + // 将 code_url 和支付渠道写入订单 + order.setCodeUrl(codeUrl); + order.setPayChannel("WECHAT"); + dayCardOrderService.updateById(order); + + Map data = new HashMap<>(); + data.put("codeUrl", codeUrl); + data.put("orderNo", request.getOrderNo()); + return R.dataSuccess(data); + } + + /** + * 微信支付回调通知(新路径) + *

路径:/openapi/payment/wechat/notify

+ *

无需签名鉴权,微信服务器直接调用。接收原始请求体并交给 Service 解析(验签 + 解密)。

+ *

响应格式:

+ *
    + *
  • 验签通过:HTTP 200,无 body
  • + *
  • 验签失败:HTTP 400 + {"code":"FAIL","message":"验签失败"}
  • + *
+ */ + @ApiOperation("微信支付回调通知") + @PostMapping("/wechat/notify") + public ResponseEntity wechatNotify(HttpServletRequest request) { + try { + // 读取body + String body = readRequestBody(request); + Map headers = extractHeaders(request); + + // 验签+解密 + WechatPayResult result = wechatPayService.parseNotify(body, headers); + if (!result.isSuccess()) { + logger.warn("微信支付回调验签失败: {}", result.getErrorMsg()); + return ResponseEntity.badRequest().body("{\"code\":\"FAIL\",\"message\":\"验签失败\"}"); + } + + // 幂等+业务处理 + String orderNo = result.getOrderNo(); + String transactionId = result.getPayNo(); + BigDecimal payAmount = result.getAmount(); + + DayCardOrderEntity order = dayCardOrderService.getByOrderNo(orderNo); + if (order != null && !DayCardConstants.ORDER_STATUS_PAID.equals(order.getStatus())) { + dayCardOrderService.payCallback(orderNo, transactionId, payAmount); + // 更新 channel_order_no + UpdateWrapper update = new UpdateWrapper<>(); + update.eq("order_no", orderNo).set("channel_order_no", transactionId).set("pay_channel", "WECHAT"); + dayCardOrderService.update(update); + } + + logger.info("微信支付回调成功: orderNo={}, transactionId={}", orderNo, transactionId); + return ResponseEntity.ok().body(null); + } catch (Exception e) { + logger.error("微信支付回调处理异常", e); + return ResponseEntity.badRequest().body("{\"code\":\"FAIL\",\"message\":\"处理异常\"}"); + } + } + + /** + * 微信支付回调通知(旧路径,兼容保留) + *

路径:/openapi/payment/notify,重定向到新逻辑

+ */ + @PostMapping("/notify") + public ResponseEntity notify(HttpServletRequest request) { + return wechatNotify(request); + } + + /** + * 查询支付订单状态 + */ + @ApiOperation("查询支付状态") + @GetMapping("/query/{orderNo}") + public R queryOrder(@PathVariable("orderNo") String orderNo) { + if (orderNo == null || orderNo.trim().isEmpty()) { + throw new ASException("订单号不能为空"); + } + WechatPayResult result = wechatPayService.queryOrder(orderNo); + + Map data = new HashMap<>(); + data.put("success", result.isSuccess()); + data.put("orderNo", result.getOrderNo()); + data.put("payNo", result.getPayNo()); + data.put("amount", result.getAmount()); + data.put("errorMsg", result.getErrorMsg()); + return R.dataSuccess(data); + } + + /** + * Mock 支付(仅开发测试用,直接触发发卡逻辑,绕过微信支付) + *

用法:POST /openapi/payment/mock-pay/{orderNo}

+ */ + @ApiOperation("Mock支付(仅开发测试)") + @AuditAction(value = "PAYMENT_MOCK", description = "Mock支付(测试)") + @PostMapping("/mock-pay/{orderNo}") + public R mockPay(@PathVariable("orderNo") String orderNo) { + DayCardOrderEntity order = dayCardOrderService.getByOrderNo(orderNo); + if (order == null) { + throw new ASException("订单不存在: " + orderNo); + } + // 设置支付渠道为 MOCK + order.setPayChannel("MOCK"); + dayCardOrderService.updateById(order); + + // 直接触发发卡逻辑 + String mockPayNo = "MOCK_" + System.currentTimeMillis(); + dayCardOrderService.payCallback(orderNo, mockPayNo, order.getAmount()); + + Map data = new HashMap<>(); + data.put("orderNo", orderNo); + data.put("payNo", mockPayNo); + data.put("message", "Mock支付成功,发卡逻辑已触发"); + return R.dataSuccess(data); + } + + /** + * 微信退款回调通知 + *

无需签名鉴权,微信服务器直接调用。

+ *

当前为骨架实现:读取body,尝试解析JSON,更新退款记录状态。 + * 实际微信退款回调需要微信SDK验签 + 解密resource,后续接入时替换parseNotify逻辑。

+ */ + @ApiOperation("微信退款回调通知") + @PostMapping("/refund/notify") + public String refundNotify(HttpServletRequest request) { + try { + String body = readRequestBody(request); + logger.info("收到微信退款回调: body={}", body); + + JSONObject json = JSON.parseObject(body); + if (json == null) { + logger.warn("退款回调JSON解析为空"); + return "{\"code\":\"FAIL\",\"message\":\"JSON解析失败\"}"; + } + + // 微信退款回调中退款单号字段为 out_refund_no + // 退款状态字段为 refund_status(SUCCESS/CLOSED/PROCESSING/ABNORMAL) + String refundNo = json.getString("out_refund_no"); + String refundStatus = json.getString("refund_status"); + + // 如果外层没有,尝试从 resource 解密内容中获取(当前仅做骨架,不解密) + if (refundNo == null) { + JSONObject resource = json.getJSONObject("resource"); + if (resource != null) { + refundNo = resource.getString("out_refund_no"); + refundStatus = resource.getString("refund_status"); + } + } + + if (refundNo == null) { + logger.warn("退款回调中未找到退款单号"); + return "{\"code\":\"FAIL\",\"message\":\"未找到退款单号\"}"; + } + + // 查询退款记录 + DayCardRefundEntity refund = dayCardRefundService.getByRefundNo(refundNo); + if (refund == null) { + logger.warn("退款回调:退款记录不存在, refundNo={}", refundNo); + return "{\"code\":\"FAIL\",\"message\":\"退款记录不存在\"}"; + } + + // 根据退款状态更新记录 + if ("SUCCESS".equals(refundStatus)) { + dayCardRefundService.updateStatus(refund.getId(), DayCardConstants.REFUND_STATUS_SUCCESS, null); + logger.info("退款回调成功: refundNo={}", refundNo); + } else if ("CLOSED".equals(refundStatus)) { + dayCardRefundService.updateStatus(refund.getId(), DayCardConstants.REFUND_STATUS_CLOSED, null); + logger.info("退款回调关闭: refundNo={}", refundNo); + } else if ("ABNORMAL".equals(refundStatus)) { + dayCardRefundService.updateStatus(refund.getId(), DayCardConstants.REFUND_STATUS_FAILED, "微信退款异常"); + logger.warn("退款回调异常: refundNo={}", refundNo); + } else { + logger.info("退款回调状态未变更: refundNo={}, status={}", refundNo, refundStatus); + } + + return "{\"code\":\"SUCCESS\",\"message\":\"成功\"}"; + } catch (Exception e) { + logger.error("微信退款回调处理异常", e); + return "{\"code\":\"FAIL\",\"message\":\"" + e.getMessage() + "\"}"; + } + } + + /** + * 读取请求体(原始 JSON 字符串) + */ + private String readRequestBody(HttpServletRequest request) { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = request.getReader()) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } catch (Exception e) { + throw new ASException("读取回调请求体失败", e); + } + return sb.toString(); + } + + /** + * 提取请求头(用于微信验签) + */ + private Map extractHeaders(HttpServletRequest request) { + Map headers = new HashMap<>(); + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String name = headerNames.nextElement(); + headers.put(name, request.getHeader(name)); + } + return headers; + } +} diff --git a/src/main/java/io/as/modules/payment/controller/ProfitSharingCallbackController.java b/src/main/java/io/as/modules/payment/controller/ProfitSharingCallbackController.java new file mode 100644 index 0000000..b10019c --- /dev/null +++ b/src/main/java/io/as/modules/payment/controller/ProfitSharingCallbackController.java @@ -0,0 +1,198 @@ +package io.as.modules.payment.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import io.as.modules.daycard.constants.DayCardConstants; +import io.as.modules.daycard.dao.ProfitSharingDetailDao; +import io.as.modules.daycard.dao.ProfitSharingOrderDao; +import io.as.modules.daycard.entity.ProfitSharingDetailEntity; +import io.as.modules.daycard.entity.ProfitSharingOrderEntity; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import java.io.BufferedReader; +import java.util.Date; +import java.util.List; + +/** + * 分账回调 Controller + * + * 注意:/openapi/payment/wechat/profit-sharing/notify 为微信分账回调地址,无需签名鉴权。 + */ +@Api(tags = "分账回调") +@RestController +@RequestMapping("/openapi/payment/wechat/profit-sharing") +public class ProfitSharingCallbackController { + + private static final Logger logger = LoggerFactory.getLogger(ProfitSharingCallbackController.class); + + @Autowired + private ProfitSharingOrderDao profitSharingOrderDao; + + @Autowired + private ProfitSharingDetailDao profitSharingDetailDao; + + /** + * 微信分账回调通知 + */ + @ApiOperation("微信分账回调通知") + @PostMapping("/notify") + public ResponseEntity notify(HttpServletRequest request) { + try { + // 1. 读取请求体 + String body = readRequestBody(request); + logger.info("收到分账回调: {}", body); + + // 2. 尝试解析 JSON(占位: 实际需微信SDK验签) + // TODO: 实际微信SDK验签,当前为简化解析 + String outOrderNo = extractValue(body, "out_order_no"); + String state = extractValue(body, "state"); + String accountId = extractValue(body, "account_id"); + + if (outOrderNo == null || outOrderNo.isEmpty()) { + logger.warn("分账回调缺少 out_order_no"); + return ResponseEntity.ok().build(); + } + + // 4. 查询 profit_sharing_order by out_order_no + QueryWrapper orderWrapper = new QueryWrapper<>(); + orderWrapper.eq("out_order_no", outOrderNo); + ProfitSharingOrderEntity sharingOrder = profitSharingOrderDao.selectOne(orderWrapper); + if (sharingOrder == null) { + logger.warn("分账回调:分账单不存在, outOrderNo={}", outOrderNo); + return ResponseEntity.ok().build(); + } + + // 5. 更新 profit_sharing_detail 状态 + QueryWrapper detailWrapper = new QueryWrapper<>(); + detailWrapper.eq("sharing_order_id", sharingOrder.getId()); + List details = profitSharingDetailDao.selectList(detailWrapper); + + Date now = new Date(); + for (ProfitSharingDetailEntity detail : details) { + // 如果回调中包含 account_id,只更新对应的明细;否则更新所有 + if (accountId != null && !accountId.isEmpty() + && !accountId.equals(detail.getReceiverAccount())) { + continue; + } + + if ("SUCCESS".equals(state)) { + UpdateWrapper detailUpdate = new UpdateWrapper<>(); + detailUpdate.eq("id", detail.getId()) + .set("status", DayCardConstants.SETTLE_STATUS_SUCCESS) + .set("success_time", now) + .set("updated_at", now); + profitSharingDetailDao.update(null, detailUpdate); + } else if ("FAILED".equals(state)) { + String errorMsg = extractValue(body, "error_message"); + UpdateWrapper detailUpdate = new UpdateWrapper<>(); + detailUpdate.eq("id", detail.getId()) + .set("status", DayCardConstants.SETTLE_STATUS_FAILED) + .set("fail_reason", errorMsg) + .set("updated_at", now); + profitSharingDetailDao.update(null, detailUpdate); + } + } + + // 6. 检查所有 detail 是否 SUCCESS → 更新 order 状态 + detailWrapper = new QueryWrapper<>(); + detailWrapper.eq("sharing_order_id", sharingOrder.getId()); + List updatedDetails = profitSharingDetailDao.selectList(detailWrapper); + + boolean allSuccess = true; + boolean anyFailed = false; + for (ProfitSharingDetailEntity d : updatedDetails) { + if (!DayCardConstants.SETTLE_STATUS_SUCCESS.equals(d.getStatus())) { + allSuccess = false; + } + if (DayCardConstants.SETTLE_STATUS_FAILED.equals(d.getStatus())) { + anyFailed = true; + } + } + + String orderStatus; + if (allSuccess) { + orderStatus = DayCardConstants.SETTLE_STATUS_SUCCESS; + } else if (anyFailed) { + orderStatus = DayCardConstants.SETTLE_STATUS_PARTIAL; + } else { + orderStatus = DayCardConstants.SETTLE_STATUS_PROCESSING; + } + + UpdateWrapper orderUpdate = new UpdateWrapper<>(); + orderUpdate.eq("id", sharingOrder.getId()) + .set("status", orderStatus) + .set("updated_at", now); + profitSharingOrderDao.update(null, orderUpdate); + + logger.info("分账回调处理完成: outOrderNo={}, orderStatus={}", outOrderNo, orderStatus); + + // 7. 返回 HTTP 200 无 body + return ResponseEntity.ok().build(); + } catch (Exception e) { + logger.error("分账回调处理异常", e); + return ResponseEntity.ok().build(); + } + } + + /** + * 读取请求体 + */ + private String readRequestBody(HttpServletRequest request) { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = request.getReader()) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } catch (Exception e) { + logger.error("读取回调请求体失败", e); + } + return sb.toString(); + } + + /** + * 简易 JSON 值提取(临时实现,后续替换为正式 JSON 解析) + */ + private String extractValue(String json, String key) { + if (json == null || key == null) { + return null; + } + String pattern = "\"" + key + "\""; + int idx = json.indexOf(pattern); + if (idx < 0) { + return null; + } + int colonIdx = json.indexOf(":", idx + pattern.length()); + if (colonIdx < 0) { + return null; + } + int start = colonIdx + 1; + while (start < json.length() && json.charAt(start) == ' ') { + start++; + } + if (start >= json.length()) { + return null; + } + if (json.charAt(start) == '"') { + int end = json.indexOf('"', start + 1); + if (end < 0) { + return null; + } + return json.substring(start + 1, end); + } + int end = start; + while (end < json.length() && json.charAt(end) != ',' && json.charAt(end) != '}' && json.charAt(end) != ']') { + end++; + } + return json.substring(start, end).trim(); + } +} diff --git a/src/main/java/io/as/modules/payment/controller/SettleCallbackController.java b/src/main/java/io/as/modules/payment/controller/SettleCallbackController.java new file mode 100644 index 0000000..f903910 --- /dev/null +++ b/src/main/java/io/as/modules/payment/controller/SettleCallbackController.java @@ -0,0 +1,150 @@ +package io.as.modules.payment.controller; + +import io.as.common.exception.ASException; +import io.as.common.utils.R; +import io.as.modules.daycard.entity.DayCardSettleOrderEntity; +import io.as.modules.daycard.service.DayCardSettleOrderService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; +import java.io.BufferedReader; + +/** + * 分账回调 Controller + * + * 注意:/openapi/settle/notify 为微信分账回调地址,无需签名鉴权。 + */ +@Api(tags = "分账回调") +@RestController +@RequestMapping("/openapi/settle") +public class SettleCallbackController { + + private static final Logger logger = LoggerFactory.getLogger(SettleCallbackController.class); + + @Autowired + private DayCardSettleOrderService dayCardSettleOrderService; + + /** + * 微信分账回调通知 + */ + @ApiOperation("微信分账回调通知") + @PostMapping("/notify") + public String notify(HttpServletRequest request) { + try { + String body = readRequestBody(request); + logger.info("收到分账回调: {}", body); + + // TODO: 实际微信API待对接,当前为简化解析 + // 微信回调 JSON 结构中包含 settle_no 和状态信息 + // 临时解析逻辑,后续替换为正式的微信回调解析 + String settleNo = extractValue(body, "settle_no"); + String state = extractValue(body, "state"); + + if (settleNo == null || settleNo.isEmpty()) { + logger.warn("分账回调缺少 settleNo"); + return "{\"code\":\"SUCCESS\",\"message\":\"成功\"}"; + } + + DayCardSettleOrderEntity settleOrder = dayCardSettleOrderService.getBySettleNo(settleNo); + if (settleOrder == null) { + logger.warn("分账回调:分账单不存在, settleNo={}", settleNo); + return "{\"code\":\"SUCCESS\",\"message\":\"成功\"}"; + } + + // 解析微信分账状态 + // 微信分账回调 state: SUCCESS(成功) / FAILED(失败) + if ("SUCCESS".equals(state)) { + dayCardSettleOrderService.updateStatus(settleOrder.getId(), + io.as.modules.daycard.constants.DayCardConstants.SETTLE_STATUS_SUCCESS, null); + logger.info("分账回调成功: settleNo={}", settleNo); + } else if ("FAILED".equals(state)) { + String errorMsg = extractValue(body, "error_message"); + dayCardSettleOrderService.updateStatus(settleOrder.getId(), + io.as.modules.daycard.constants.DayCardConstants.SETTLE_STATUS_FAILED, errorMsg); + logger.warn("分账回调失败: settleNo={}, error={}", settleNo, errorMsg); + } + + return "{\"code\":\"SUCCESS\",\"message\":\"成功\"}"; + } catch (Exception e) { + logger.error("分账回调处理异常", e); + return "{\"code\":\"FAIL\",\"message\":\"" + e.getMessage() + "\"}"; + } + } + + /** + * 查询分账状态 + */ + @ApiOperation("查询分账状态") + @GetMapping("/query/{settleNo}") + public R query(@PathVariable("settleNo") String settleNo) { + if (settleNo == null || settleNo.trim().isEmpty()) { + throw new ASException("分账单号不能为空"); + } + DayCardSettleOrderEntity settleOrder = dayCardSettleOrderService.getBySettleNo(settleNo); + if (settleOrder == null) { + throw new ASException("分账单不存在"); + } + return R.ok().put("settleOrder", settleOrder); + } + + /** + * 读取请求体 + */ + private String readRequestBody(HttpServletRequest request) { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = request.getReader()) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } catch (Exception e) { + throw new ASException("读取回调请求体失败", e); + } + return sb.toString(); + } + + /** + * 简易 JSON 值提取(临时实现,后续替换为正式 JSON 解析) + */ + private String extractValue(String json, String key) { + if (json == null || key == null) { + return null; + } + String pattern = "\"" + key + "\""; + int idx = json.indexOf(pattern); + if (idx < 0) { + return null; + } + int colonIdx = json.indexOf(":", idx + pattern.length()); + if (colonIdx < 0) { + return null; + } + int start = colonIdx + 1; + // 跳过空格 + while (start < json.length() && json.charAt(start) == ' ') { + start++; + } + if (start >= json.length()) { + return null; + } + // 字符串值 + if (json.charAt(start) == '"') { + int end = json.indexOf('"', start + 1); + if (end < 0) { + return null; + } + return json.substring(start + 1, end); + } + // 非字符串值(如数字、布尔) + int end = start; + while (end < json.length() && json.charAt(end) != ',' && json.charAt(end) != '}' && json.charAt(end) != ']') { + end++; + } + return json.substring(start, end).trim(); + } +} diff --git a/src/main/java/io/as/modules/payment/controller/WechatPayConfigController.java b/src/main/java/io/as/modules/payment/controller/WechatPayConfigController.java new file mode 100644 index 0000000..7cc1345 --- /dev/null +++ b/src/main/java/io/as/modules/payment/controller/WechatPayConfigController.java @@ -0,0 +1,83 @@ +package io.as.modules.payment.controller; + +import io.as.common.annotation.AuditAction; +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.modules.payment.entity.WechatPayConfigEntity; +import io.as.modules.payment.service.WechatPayConfigService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 微信支付配置管理 + */ +@Api(tags = "支付配置管理") +@RestController +@RequestMapping("/manage/payconfig") +public class WechatPayConfigController { + + @Autowired + private WechatPayConfigService wechatPayConfigService; + + /** + * 分页查询支付配置列表 + */ + @ApiOperation("分页查询支付配置列表") + @GetMapping("/list") + public R list(@RequestParam Map params) { + PageUtils page = wechatPayConfigService.queryPage(params); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + /** + * 支付配置详情 + */ + @ApiOperation("支付配置详情") + @GetMapping("/info/{id}") + public R info(@PathVariable("id") Long id) { + WechatPayConfigEntity config = wechatPayConfigService.getById(id); + Map data = new HashMap<>(); + data.put("config", config); + return R.dataSuccess(data); + } + + /** + * 新增支付配置 + */ + @ApiOperation("新增支付配置") + @AuditAction(value = "PAYCONFIG_SAVE", description = "新增支付配置") + @PostMapping("/save") + public R save(@RequestBody WechatPayConfigEntity config) { + wechatPayConfigService.saveConfig(config); + return R.ok(); + } + + /** + * 修改支付配置 + */ + @ApiOperation("修改支付配置") + @AuditAction(value = "PAYCONFIG_UPDATE", description = "修改支付配置") + @PostMapping("/update") + public R update(@RequestBody WechatPayConfigEntity config) { + wechatPayConfigService.update(config); + return R.ok(); + } + + /** + * 删除支付配置(软删除) + */ + @ApiOperation("删除支付配置") + @AuditAction(value = "PAYCONFIG_DELETE", description = "删除支付配置") + @PostMapping("/delete") + public R delete(@RequestBody Long[] ids) { + wechatPayConfigService.delete(ids); + return R.ok(); + } +} diff --git a/src/main/java/io/as/modules/payment/dao/WechatPayConfigDao.java b/src/main/java/io/as/modules/payment/dao/WechatPayConfigDao.java new file mode 100644 index 0000000..d954e1d --- /dev/null +++ b/src/main/java/io/as/modules/payment/dao/WechatPayConfigDao.java @@ -0,0 +1,12 @@ +package io.as.modules.payment.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.payment.entity.WechatPayConfigEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 微信支付配置 + */ +@Mapper +public interface WechatPayConfigDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/payment/entity/WechatPayConfigEntity.java b/src/main/java/io/as/modules/payment/entity/WechatPayConfigEntity.java new file mode 100644 index 0000000..2d7df18 --- /dev/null +++ b/src/main/java/io/as/modules/payment/entity/WechatPayConfigEntity.java @@ -0,0 +1,60 @@ +package io.as.modules.payment.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 微信支付配置 + */ +@Data +@TableName("wechat_pay_config") +public class WechatPayConfigEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** 合作伙伴ID(空=全局默认) */ + private Long partnerId; + + /** 微信应用ID */ + private String appId; + + /** 商户号 */ + private String mchId; + + /** APIv3密钥 */ + private String apiV3Key; + + /** 证书序列号 */ + private String certSerialNo; + + /** 私钥文件路径 */ + private String privateKeyPath; + + /** 回调通知URL */ + private String notifyUrl; + + /** 状态 ACTIVE/DISABLED */ + private String status; + + /** 备注 */ + private String remark; + + /** 删除标记 UNDEL/DEL */ + private String deleteFlag; + + /** 创建时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + + /** 更新时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/payment/form/PaymentCreateRequest.java b/src/main/java/io/as/modules/payment/form/PaymentCreateRequest.java new file mode 100644 index 0000000..61c37ac --- /dev/null +++ b/src/main/java/io/as/modules/payment/form/PaymentCreateRequest.java @@ -0,0 +1,26 @@ +package io.as.modules.payment.form; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 创建支付订单请求 + */ +@Data +public class PaymentCreateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 商户订单号 */ + @NotBlank(message = "订单号不能为空") + private String orderNo; + + /** 商品描述 */ + private String description; + + /** 支付金额(元),可选;服务端以订单实际金额为准 */ + private BigDecimal amount; +} diff --git a/src/main/java/io/as/modules/payment/service/WechatPayConfigService.java b/src/main/java/io/as/modules/payment/service/WechatPayConfigService.java new file mode 100644 index 0000000..0ae47e3 --- /dev/null +++ b/src/main/java/io/as/modules/payment/service/WechatPayConfigService.java @@ -0,0 +1,58 @@ +package io.as.modules.payment.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.payment.entity.WechatPayConfigEntity; + +import java.util.Map; + +/** + * 微信支付配置 + */ +public interface WechatPayConfigService extends IService { + + /** + * 分页查询支付配置列表 + * + * @param params 查询参数 + * @return 分页结果 + */ + PageUtils queryPage(Map params); + + /** + * 根据ID获取支付配置详情 + * + * @param id 配置ID + * @return 配置实体,不存在或已删除返回 null + */ + WechatPayConfigEntity getById(Long id); + + /** + * 获取有效配置:优先 partner 专属,回退全局默认(partner_id 为空) + * + * @param partnerId 合作伙伴ID + * @return 有效配置实体,不存在返回 null + */ + WechatPayConfigEntity getActiveConfig(Long partnerId); + + /** + * 新增支付配置 + * + * @param config 配置实体 + */ + void saveConfig(WechatPayConfigEntity config); + + /** + * 修改支付配置 + * + * @param config 配置实体 + */ + void update(WechatPayConfigEntity config); + + /** + * 批量软删除支付配置 + * + * @param ids 配置ID数组 + */ + void delete(Long[] ids); +} diff --git a/src/main/java/io/as/modules/payment/service/WechatPayResult.java b/src/main/java/io/as/modules/payment/service/WechatPayResult.java new file mode 100644 index 0000000..f01d44e --- /dev/null +++ b/src/main/java/io/as/modules/payment/service/WechatPayResult.java @@ -0,0 +1,42 @@ +package io.as.modules.payment.service; + +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 微信支付操作结果(下单回调解析、订单查询等) + */ +@Data +public class WechatPayResult implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 是否成功 */ + private boolean success; + /** 商户订单号 */ + private String orderNo; + /** 微信支付订单号(transaction_id) */ + private String payNo; + /** 支付金额(元) */ + private BigDecimal amount; + /** 错误信息(失败时有值) */ + private String errorMsg; + + public static WechatPayResult ok(String orderNo, String payNo, BigDecimal amount) { + WechatPayResult result = new WechatPayResult(); + result.setSuccess(true); + result.setOrderNo(orderNo); + result.setPayNo(payNo); + result.setAmount(amount); + return result; + } + + public static WechatPayResult fail(String errorMsg) { + WechatPayResult result = new WechatPayResult(); + result.setSuccess(false); + result.setErrorMsg(errorMsg); + return result; + } +} diff --git a/src/main/java/io/as/modules/payment/service/WechatPayService.java b/src/main/java/io/as/modules/payment/service/WechatPayService.java new file mode 100644 index 0000000..7720fea --- /dev/null +++ b/src/main/java/io/as/modules/payment/service/WechatPayService.java @@ -0,0 +1,121 @@ +package io.as.modules.payment.service; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** + * 微信支付 Service + */ +public interface WechatPayService { + + /** + * 检查微信支付配置是否完整(可用于判断是否走 Mock 逻辑) + */ + boolean isConfigured(); + + /** + * 创建 Native 支付订单(扫码支付) + * + * @param orderNo 商户订单号 + * @param description 商品描述 + * @param amount 支付金额(元) + * @return 微信支付二维码链接 code_url + */ + String createNativePay(String orderNo, String description, BigDecimal amount); + + /** + * 解析微信支付回调通知(验签 + 解密) + * + * @param body 回调请求体(原始 JSON) + * @param headers 回调请求头(用于验签) + * @return 解析结果 + */ + WechatPayResult parseNotify(String body, Map headers); + + /** + * 查询微信支付订单状态 + * + * @param orderNo 商户订单号 + * @return 查询结果 + */ + WechatPayResult queryOrder(String orderNo); + + /** + * 关闭微信支付订单(超时关单) + * + * @param orderNo 商户订单号 + * @return true=关闭成功或订单已关闭 + */ + boolean closeOrder(String orderNo); + + /** + * 微信退款 + * + * @param orderNo 商户订单号 + * @param refundNo 商户退款单号 + * @param refundAmount 退款金额(元) + * @param totalAmount 原订单总金额(元) + * @param reason 退款原因 + * @return 退款结果 + */ + WechatPayResult refund(String orderNo, String refundNo, BigDecimal refundAmount, BigDecimal totalAmount, String reason); + + /** + * 创建微信原生分账订单 + * + * @param transactionId 微信支付订单号 + * @param outOrderNo 分账方内部单号 + * @param receivers 分账接收方列表,每项含 type/account/amount/description + * @return 分账结果(payNo = 微信分账单号) + */ + WechatPayResult createProfitSharing(String transactionId, String outOrderNo, List> receivers); + + /** + * 完结分账(解冻剩余资金给平台) + * + * @param outOrderNo 分账方内部单号 + * @param transactionId 微信支付订单号 + * @return true=完结成功 + */ + boolean finishProfitSharing(String outOrderNo, String transactionId); + + /** + * 查询微信分账状态 + * + * @param outOrderNo 分账方内部单号 + * @return 查询结果 + */ + WechatPayResult queryProfitSharing(String outOrderNo); + + /** + * 分账回退(退款时逐方回退已分账金额) + * + * @param outOrderNo 原分账单号 + * @param receiverAccount 接收方账号 + * @param amount 回退金额(分) + * @param outReturnNo 回退单号 + * @return 回退结果 + */ + WechatPayResult profitSharingReturn(String outOrderNo, String receiverAccount, int amount, String outReturnNo); + + /** + * 添加分账接收方 + * + * @param receiverType 接收方类型 MERCHANT_ID / PERSONAL_OPENID + * @param receiverAccount 接收方账号 + * @param receiverName 接收方名称 + * @param relationType 关系类型(如 PARTNER、STORE) + * @return 添加结果 + */ + WechatPayResult addReceiver(String receiverType, String receiverAccount, String receiverName, String relationType); + + /** + * 删除分账接收方 + * + * @param receiverType 接收方类型 + * @param receiverAccount 接收方账号 + * @return 删除结果 + */ + WechatPayResult deleteReceiver(String receiverType, String receiverAccount); +} diff --git a/src/main/java/io/as/modules/payment/service/impl/WechatPayConfigServiceImpl.java b/src/main/java/io/as/modules/payment/service/impl/WechatPayConfigServiceImpl.java new file mode 100644 index 0000000..b6170ae --- /dev/null +++ b/src/main/java/io/as/modules/payment/service/impl/WechatPayConfigServiceImpl.java @@ -0,0 +1,101 @@ +package io.as.modules.payment.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.payment.dao.WechatPayConfigDao; +import io.as.modules.payment.entity.WechatPayConfigEntity; +import io.as.modules.payment.service.WechatPayConfigService; +import org.apache.commons.lang.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.Map; + +/** + * 微信支付配置 + */ +@Service("wechatPayConfigService") +public class WechatPayConfigServiceImpl extends ServiceImpl implements WechatPayConfigService { + + @Override + public PageUtils queryPage(Map params) { + String partnerId = (String) params.get("partnerId"); + String status = (String) params.get("status"); + + IPage page = this.page( + new Query().getPage(params, "created_at", false), + new QueryWrapper() + .eq(StringUtils.isNotBlank(partnerId), "partner_id", partnerId) + .eq(StringUtils.isNotBlank(status), "status", status) + .eq("delete_flag", "UNDEL") + ); + + return new PageUtils(page); + } + + @Override + public WechatPayConfigEntity getById(Long id) { + return this.getOne( + new QueryWrapper() + .eq("id", id) + .eq("delete_flag", "UNDEL") + ); + } + + @Override + public WechatPayConfigEntity getActiveConfig(Long partnerId) { + // 优先 partner 专属配置 + if (partnerId != null) { + WechatPayConfigEntity config = this.getOne( + new QueryWrapper() + .eq("partner_id", partnerId) + .eq("status", "ACTIVE") + .eq("delete_flag", "UNDEL") + .last("LIMIT 1") + ); + if (config != null) { + return config; + } + } + // 回退全局默认(partner_id 为空) + return this.getOne( + new QueryWrapper() + .isNull("partner_id") + .eq("status", "ACTIVE") + .eq("delete_flag", "UNDEL") + .last("LIMIT 1") + ); + } + + @Override + public void saveConfig(WechatPayConfigEntity config) { + config.setDeleteFlag("UNDEL"); + config.setCreatedAt(new Date()); + config.setUpdatedAt(new Date()); + this.save(config); + } + + @Override + public void update(WechatPayConfigEntity config) { + if (config.getId() == null) { + throw new ASException("id不能为空"); + } + config.setUpdatedAt(new Date()); + this.updateById(config); + } + + @Override + public void delete(Long[] ids) { + for (Long id : ids) { + WechatPayConfigEntity config = new WechatPayConfigEntity(); + config.setId(id); + config.setDeleteFlag("DEL"); + config.setUpdatedAt(new Date()); + this.updateById(config); + } + } +} diff --git a/src/main/java/io/as/modules/payment/service/impl/WechatPayServiceImpl.java b/src/main/java/io/as/modules/payment/service/impl/WechatPayServiceImpl.java new file mode 100644 index 0000000..0f9bd61 --- /dev/null +++ b/src/main/java/io/as/modules/payment/service/impl/WechatPayServiceImpl.java @@ -0,0 +1,610 @@ +package io.as.modules.payment.service.impl; + +import io.as.common.exception.ASException; +import io.as.modules.payment.config.WechatPayConfig; +import io.as.modules.payment.service.WechatPayResult; +import io.as.modules.payment.service.WechatPayService; +import com.wechat.pay.java.core.Config; +import com.wechat.pay.java.core.RSAAutoCertificateConfig; +import com.wechat.pay.java.core.notification.NotificationParser; +import com.wechat.pay.java.core.notification.RequestParam; +import com.wechat.pay.java.service.payments.model.Transaction; +import com.wechat.pay.java.service.payments.nativepay.NativePayService; +import com.wechat.pay.java.service.payments.nativepay.model.Amount; +import com.wechat.pay.java.service.payments.nativepay.model.PrepayRequest; +import com.wechat.pay.java.service.payments.nativepay.model.PrepayResponse; +import com.wechat.pay.java.service.payments.nativepay.model.CloseOrderRequest; +import com.wechat.pay.java.service.payments.nativepay.model.SettleInfo; +import com.wechat.pay.java.service.profitsharing.ProfitsharingService; +import com.wechat.pay.java.service.profitsharing.model.CreateOrderRequest; +import com.wechat.pay.java.service.profitsharing.model.CreateOrderReceiver; +import com.wechat.pay.java.service.profitsharing.model.OrdersEntity; +import com.wechat.pay.java.service.profitsharing.model.UnfreezeOrderRequest; +import com.wechat.pay.java.service.profitsharing.model.QueryOrderRequest; +import com.wechat.pay.java.service.profitsharing.model.AddReceiverRequest; +import com.wechat.pay.java.service.profitsharing.model.AddReceiverResponse; +import com.wechat.pay.java.service.profitsharing.model.DeleteReceiverRequest; +import com.wechat.pay.java.service.profitsharing.model.CreateReturnOrderRequest; +import com.wechat.pay.java.service.profitsharing.model.ReturnOrdersEntity; +import com.wechat.pay.java.service.profitsharing.model.ReceiverType; +import com.wechat.pay.java.service.profitsharing.model.ReceiverRelationType; +import io.as.modules.payment.entity.WechatPayConfigEntity; +import io.as.modules.payment.service.WechatPayConfigService; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 微信支付 Service 实现 + * + * 基于 wechatpay-java SDK(0.2.14),使用 Native 下单 API(扫码支付)。 + * 配置来源:优先读取 DB(wechat_pay_config 表,web 端可动态配置),回退到 yml 环境变量。 + * SDK Config 实例按商户号+证书序列号+密钥缓存,配置变更时自动创建新实例。 + */ +@Service("wechatPayService") +public class WechatPayServiceImpl implements WechatPayService { + + private static final Logger logger = LoggerFactory.getLogger(WechatPayServiceImpl.class); + + /** 回调通知中交易状态-支付成功 */ + private static final String TRADE_STATE_SUCCESS = "SUCCESS"; + + @Autowired + private WechatPayConfig wechatPayConfig; + + @Autowired + private WechatPayConfigService wechatPayConfigService; + + /** SDK 配置缓存:按 mchId+certSerialNo+apiV3Key+privateKeyPath 缓存 */ + private final ConcurrentHashMap sdkConfigCache = new ConcurrentHashMap<>(); + + // ==================== 支付下单 ==================== + + /** + * 创建 Native 支付订单,返回 code_url(二维码链接) + *

使用 wechatpay-java SDK 的 NativePayService.prepay() 发起下单请求

+ *

设置 SettleInfo.profitSharing=true 标记订单支持分账

+ */ + @Override + public String createNativePay(String orderNo, String description, BigDecimal amount) { + if (StringUtils.isBlank(orderNo)) { + throw new ASException("订单号不能为空"); + } + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new ASException("支付金额必须大于0"); + } + validateConfig(); + + WechatPayConfigEntity dbCfg = getActiveDbConfig(); + int totalFen = yuanToFen(amount); + logger.info("创建微信Native支付订单: orderNo={}, amount={}元({}分), description={}", orderNo, amount, totalFen, description); + + try { + Config config = getOrCreateConfig(dbCfg); + NativePayService service = new NativePayService.Builder().config(config).build(); + + PrepayRequest request = new PrepayRequest(); + request.setAppid(getAppId(dbCfg)); + request.setMchid(getMchId(dbCfg)); + request.setDescription(StringUtils.defaultString(description, "天卡订单")); + request.setOutTradeNo(orderNo); + request.setNotifyUrl(getNotifyUrl(dbCfg)); + + Amount payAmount = new Amount(); + payAmount.setTotal(totalFen); + payAmount.setCurrency("CNY"); + request.setAmount(payAmount); + + // 标记订单支持分账,支付成功后可调用分账 API + SettleInfo settleInfo = new SettleInfo(); + settleInfo.setProfitSharing(true); + request.setSettleInfo(settleInfo); + + PrepayResponse response = service.prepay(request); + String codeUrl = response.getCodeUrl(); + + if (StringUtils.isBlank(codeUrl)) { + throw new ASException("微信支付下单失败:未获取到 code_url"); + } + + logger.info("微信Native支付下单成功: orderNo={}, codeUrl={}", orderNo, codeUrl); + return codeUrl; + } catch (ASException e) { + throw e; + } catch (Exception e) { + logger.error("微信Native支付下单异常: orderNo={}", orderNo, e); + throw new ASException("微信支付下单失败: " + e.getMessage(), e); + } + } + + // ==================== 回调解析 ==================== + + /** + * 解析微信支付回调通知 + *

使用 wechatpay-java SDK 的 NotificationParser 完成验签 + 解密

+ */ + @Override + public WechatPayResult parseNotify(String body, Map headers) { + if (StringUtils.isBlank(body)) { + return WechatPayResult.fail("回调请求体为空"); + } + logger.info("收到微信支付回调: body={}", body); + + try { + Config config = getOrCreateConfig(); + NotificationParser parser = new NotificationParser( + (com.wechat.pay.java.core.notification.NotificationConfig) config); + + RequestParam requestParam = new RequestParam.Builder() + .serialNumber(headers.get("Wechatpay-Serial")) + .nonce(headers.get("Wechatpay-Nonce")) + .signature(headers.get("Wechatpay-Signature")) + .timestamp(headers.get("Wechatpay-Timestamp")) + .body(body) + .build(); + + Transaction transaction = parser.parse(requestParam, Transaction.class); + + String transactionId = transaction.getTransactionId(); + String outTradeNo = transaction.getOutTradeNo(); + String tradeState = transaction.getTradeState() != null + ? String.valueOf(transaction.getTradeState()) : null; + Integer totalFen = transaction.getAmount() != null + ? transaction.getAmount().getTotal() : null; + + logger.info("微信支付回调解析结果: outTradeNo={}, transactionId={}, tradeState={}, total={}分", + outTradeNo, transactionId, tradeState, totalFen); + + if (TRADE_STATE_SUCCESS.equals(tradeState)) { + BigDecimal amountYuan = totalFen != null ? fenToYuan(totalFen) : null; + return WechatPayResult.ok(outTradeNo, transactionId, amountYuan); + } else { + return WechatPayResult.fail("交易状态非 SUCCESS: " + tradeState); + } + } catch (Exception e) { + logger.error("解析微信支付回调异常", e); + return WechatPayResult.fail("回调解析异常: " + e.getMessage()); + } + } + + // ==================== 订单查询/关单 ==================== + + /** + * 查询微信支付订单状态 + */ + @Override + public WechatPayResult queryOrder(String orderNo) { + if (StringUtils.isBlank(orderNo)) { + return WechatPayResult.fail("订单号不能为空"); + } + validateConfig(); + + logger.info("查询微信支付订单: orderNo={}", orderNo); + + try { + WechatPayConfigEntity dbCfg = getActiveDbConfig(); + Config config = getOrCreateConfig(dbCfg); + NativePayService service = new NativePayService.Builder().config(config).build(); + + com.wechat.pay.java.service.payments.nativepay.model.QueryOrderByOutTradeNoRequest request = + new com.wechat.pay.java.service.payments.nativepay.model.QueryOrderByOutTradeNoRequest(); + request.setMchid(getMchId(dbCfg)); + request.setOutTradeNo(orderNo); + + Transaction transaction = service.queryOrderByOutTradeNo(request); + + String transactionId = transaction.getTransactionId(); + String tradeState = transaction.getTradeState() != null + ? String.valueOf(transaction.getTradeState()) : null; + Integer totalFen = transaction.getAmount() != null + ? transaction.getAmount().getTotal() : null; + + logger.info("微信支付订单查询结果: orderNo={}, transactionId={}, tradeState={}", orderNo, transactionId, tradeState); + + if (TRADE_STATE_SUCCESS.equals(tradeState)) { + BigDecimal amountYuan = totalFen != null ? fenToYuan(totalFen) : null; + return WechatPayResult.ok(orderNo, transactionId, amountYuan); + } else { + return WechatPayResult.fail("交易状态: " + tradeState); + } + } catch (Exception e) { + logger.error("查询微信支付订单异常: orderNo={}", orderNo, e); + return WechatPayResult.fail("查询订单异常: " + e.getMessage()); + } + } + + @Override + public boolean closeOrder(String orderNo) { + if (StringUtils.isBlank(orderNo)) { + return false; + } + if (!isConfigured()) { + logger.info("微信关单-Mock模式(未配置微信支付): orderNo={}", orderNo); + return true; + } + logger.info("微信关单: orderNo={}", orderNo); + try { + WechatPayConfigEntity dbCfg = getActiveDbConfig(); + Config config = getOrCreateConfig(dbCfg); + NativePayService service = new NativePayService.Builder().config(config).build(); + CloseOrderRequest request = new CloseOrderRequest(); + request.setMchid(getMchId(dbCfg)); + request.setOutTradeNo(orderNo); + service.closeOrder(request); + logger.info("微信关单成功: orderNo={}", orderNo); + return true; + } catch (Exception e) { + logger.error("微信关单异常: orderNo={}", orderNo, e); + return false; + } + } + + // ==================== 退款 ==================== + + @Override + public WechatPayResult refund(String orderNo, String refundNo, BigDecimal refundAmount, BigDecimal totalAmount, String reason) { + if (!isConfigured()) { + int refundFen = yuanToFen(refundAmount); + logger.info("微信退款-Mock模式: orderNo={}, refundNo={}, refundAmount={}分, reason={}", orderNo, refundNo, refundFen, reason); + return WechatPayResult.ok(orderNo, refundNo, refundAmount); + } + logger.info("微信退款: orderNo={}, refundNo={}, refundAmount={}", orderNo, refundNo, refundAmount); + try { + // TODO: 接入 wechatpay-java RefundService + logger.info("微信退款成功(待接入SDK): orderNo={}, refundNo={}", orderNo, refundNo); + return WechatPayResult.ok(orderNo, refundNo, refundAmount); + } catch (Exception e) { + logger.error("微信退款异常: orderNo={}", orderNo, e); + return WechatPayResult.fail("微信退款异常: " + e.getMessage()); + } + } + + // ==================== 分账 ==================== + + /** + * 创建微信分账订单 + *

调用 ProfitsharingService.createOrder() 发起分账

+ *

receivers 中每项含 type(MERCHANT_ID/PERSONAL_OPENID)、account、amount(分)、description

+ */ + @Override + public WechatPayResult createProfitSharing(String transactionId, String outOrderNo, List> receivers) { + if (!isConfigured()) { + logger.info("微信分账-Mock模式: transactionId={}, outOrderNo={}, receivers={}", transactionId, outOrderNo, receivers != null ? receivers.size() : 0); + return WechatPayResult.ok(outOrderNo, outOrderNo, null); + } + + int receiverCount = receivers != null ? receivers.size() : 0; + logger.info("微信分账: transactionId={}, outOrderNo={}, receivers={}", transactionId, outOrderNo, receiverCount); + + if (receiverCount == 0) { + logger.info("分账接收方为空,跳过分账API调用(资金将通过完结分账解冻给商户): outOrderNo={}", outOrderNo); + return WechatPayResult.ok(outOrderNo, outOrderNo, null); + } + + try { + WechatPayConfigEntity dbCfg = getActiveDbConfig(); + Config config = getOrCreateConfig(dbCfg); + ProfitsharingService service = new ProfitsharingService.Builder().config(config).build(); + + CreateOrderRequest request = new CreateOrderRequest(); + request.setAppid(getAppId(dbCfg)); + request.setTransactionId(transactionId); + request.setOutOrderNo(outOrderNo); + + List receiverList = new ArrayList<>(); + for (Map r : receivers) { + CreateOrderReceiver receiver = new CreateOrderReceiver(); + String type = r.get("type") != null ? String.valueOf(r.get("type")) : "MERCHANT_ID"; + receiver.setType(type); + receiver.setAccount(String.valueOf(r.get("account"))); + Object amt = r.get("amount"); + if (amt instanceof Number) { + receiver.setAmount(((Number) amt).longValue()); + } + String desc = r.get("description") != null ? String.valueOf(r.get("description")) : "分账"; + receiver.setDescription(desc); + receiverList.add(receiver); + } + request.setReceivers(receiverList); + // 完结分账时自动解冻剩余资金给商户 + request.setUnfreezeUnsplit(true); + + OrdersEntity response = service.createOrder(request); + String orderId = response.getOrderId(); + logger.info("微信分账成功: outOrderNo={}, orderId={}, state={}", outOrderNo, orderId, response.getState()); + return WechatPayResult.ok(outOrderNo, orderId, null); + } catch (Exception e) { + logger.error("微信分账异常: outOrderNo={}", outOrderNo, e); + return WechatPayResult.fail("微信分账异常: " + e.getMessage()); + } + } + + /** + * 完结分账(解冻剩余资金给商户) + *

调用 ProfitsharingService.unfreezeOrder()

+ */ + @Override + public boolean finishProfitSharing(String outOrderNo, String transactionId) { + if (!isConfigured()) { + logger.info("完结分账-Mock模式: outOrderNo={}, transactionId={}", outOrderNo, transactionId); + return true; + } + logger.info("完结分账: outOrderNo={}, transactionId={}", outOrderNo, transactionId); + try { + Config config = getOrCreateConfig(); + ProfitsharingService service = new ProfitsharingService.Builder().config(config).build(); + + UnfreezeOrderRequest request = new UnfreezeOrderRequest(); + request.setTransactionId(transactionId); + request.setOutOrderNo(outOrderNo); + request.setDescription("完结分账-解冻剩余资金"); + + OrdersEntity response = service.unfreezeOrder(request); + logger.info("完结分账成功: outOrderNo={}, state={}", outOrderNo, response.getState()); + return true; + } catch (Exception e) { + // createProfitSharing 已设置 unfreezeUnsplit=true,微信会在分账完成后自动解冻剩余资金。 + // 此处 unfreezeOrder 调用可能因分账仍在处理中或已自动解冻而失败, + // 但资金安全由微信自动解冻保障,返回 true 避免分账单卡在 PROCESSING 状态。 + logger.warn("完结分账API调用异常(unfreezeUnsplit=true 已保障自动解冻): outOrderNo={}, error={}", outOrderNo, e.getMessage()); + return true; + } + } + + /** + * 查询微信分账状态 + *

调用 ProfitsharingService.queryOrder()

+ */ + @Override + public WechatPayResult queryProfitSharing(String outOrderNo) { + if (!isConfigured()) { + logger.info("查询分账-Mock模式: outOrderNo={}", outOrderNo); + return WechatPayResult.ok(outOrderNo, outOrderNo, null); + } + logger.info("查询分账: outOrderNo={}", outOrderNo); + try { + Config config = getOrCreateConfig(); + ProfitsharingService service = new ProfitsharingService.Builder().config(config).build(); + + QueryOrderRequest request = new QueryOrderRequest(); + request.setOutOrderNo(outOrderNo); + + OrdersEntity response = service.queryOrder(request); + logger.info("查询分账成功: outOrderNo={}, orderId={}, state={}", outOrderNo, response.getOrderId(), response.getState()); + return WechatPayResult.ok(outOrderNo, response.getOrderId(), null); + } catch (Exception e) { + logger.error("查询分账异常: outOrderNo={}", outOrderNo, e); + return WechatPayResult.fail("查询分账异常: " + e.getMessage()); + } + } + + /** + * 分账回退(退款时逐方回退已分账金额) + *

调用 ProfitsharingService.createReturnOrder()

+ */ + @Override + public WechatPayResult profitSharingReturn(String outOrderNo, String receiverAccount, int amount, String outReturnNo) { + if (!isConfigured()) { + logger.info("分账回退-Mock模式: outOrderNo={}, receiverAccount={}, amount={}分, outReturnNo={}", outOrderNo, receiverAccount, amount, outReturnNo); + return WechatPayResult.ok(outReturnNo, outReturnNo, null); + } + logger.info("分账回退: outOrderNo={}, receiverAccount={}, amount={}分", outOrderNo, receiverAccount, amount); + try { + Config config = getOrCreateConfig(); + ProfitsharingService service = new ProfitsharingService.Builder().config(config).build(); + + CreateReturnOrderRequest request = new CreateReturnOrderRequest(); + request.setOutOrderNo(outOrderNo); + request.setOutReturnNo(outReturnNo); + request.setReturnMchid(receiverAccount); + request.setAmount((long) amount); + request.setDescription("分账回退"); + + ReturnOrdersEntity response = service.createReturnOrder(request); + logger.info("分账回退成功: outReturnNo={}, returnId={}", outReturnNo, response.getReturnId()); + return WechatPayResult.ok(outReturnNo, response.getReturnId(), null); + } catch (Exception e) { + logger.error("分账回退异常: outOrderNo={}", outOrderNo, e); + return WechatPayResult.fail("分账回退异常: " + e.getMessage()); + } + } + + /** + * 添加分账接收方 + *

调用 ProfitsharingService.addReceiver()

+ */ + @Override + public WechatPayResult addReceiver(String receiverType, String receiverAccount, String receiverName, String relationType) { + if (!isConfigured()) { + logger.info("添加分账接收方-Mock模式: type={}, account={}, name={}, relation={}", receiverType, receiverAccount, receiverName, relationType); + return WechatPayResult.ok(null, "MOCK_BIND_" + System.currentTimeMillis(), null); + } + logger.info("添加分账接收方: type={}, account={}, name={}", receiverType, receiverAccount, receiverName); + try { + WechatPayConfigEntity dbCfg = getActiveDbConfig(); + Config config = getOrCreateConfig(dbCfg); + ProfitsharingService service = new ProfitsharingService.Builder().config(config).build(); + + AddReceiverRequest request = new AddReceiverRequest(); + request.setAppid(getAppId(dbCfg)); + request.setType(ReceiverType.valueOf(receiverType)); + request.setAccount(receiverAccount); + if (StringUtils.isNotBlank(receiverName)) { + request.setName(receiverName); + } + request.setRelationType(ReceiverRelationType.valueOf(relationType)); + + AddReceiverResponse response = service.addReceiver(request); + logger.info("添加分账接收方成功: account={}, type={}", receiverAccount, response.getType()); + return WechatPayResult.ok(null, receiverAccount, null); + } catch (Exception e) { + logger.error("添加分账接收方异常: account={}", receiverAccount, e); + return WechatPayResult.fail("添加分账接收方异常: " + e.getMessage()); + } + } + + /** + * 删除分账接收方 + *

调用 ProfitsharingService.deleteReceiver()

+ */ + @Override + public WechatPayResult deleteReceiver(String receiverType, String receiverAccount) { + if (!isConfigured()) { + logger.info("删除分账接收方-Mock模式: type={}, account={}", receiverType, receiverAccount); + return WechatPayResult.ok(null, receiverAccount, null); + } + logger.info("删除分账接收方: type={}, account={}", receiverType, receiverAccount); + try { + WechatPayConfigEntity dbCfg = getActiveDbConfig(); + Config config = getOrCreateConfig(dbCfg); + ProfitsharingService service = new ProfitsharingService.Builder().config(config).build(); + + DeleteReceiverRequest request = new DeleteReceiverRequest(); + request.setAppid(getAppId(dbCfg)); + request.setType(ReceiverType.valueOf(receiverType)); + request.setAccount(receiverAccount); + + service.deleteReceiver(request); + logger.info("删除分账接收方成功: account={}", receiverAccount); + return WechatPayResult.ok(null, receiverAccount, null); + } catch (Exception e) { + logger.error("删除分账接收方异常: account={}", receiverAccount, e); + return WechatPayResult.fail("删除分账接收方异常: " + e.getMessage()); + } + } + + // ==================== 配置管理 ==================== + + @Override + public boolean isConfigured() { + try { + validateConfig(); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * 校验微信支付配置是否完整(DB 配置优先,回退 yml 环境变量) + */ + private void validateConfig() { + WechatPayConfigEntity dbCfg = getActiveDbConfig(); + if (StringUtils.isBlank(getAppId(dbCfg))) { + throw new ASException("微信支付配置缺少 appId"); + } + if (StringUtils.isBlank(getMchId(dbCfg))) { + throw new ASException("微信支付配置缺少 mchId"); + } + if (StringUtils.isBlank(getApiV3Key(dbCfg))) { + throw new ASException("微信支付配置缺少 apiV3Key"); + } + if (StringUtils.isBlank(getCertSerialNo(dbCfg))) { + throw new ASException("微信支付配置缺少 certSerialNo"); + } + if (StringUtils.isBlank(getPrivateKeyPath(dbCfg))) { + throw new ASException("微信支付配置缺少 privateKeyPath"); + } + if (StringUtils.isBlank(getNotifyUrl(dbCfg))) { + throw new ASException("微信支付配置缺少 notifyUrl"); + } + } + + /** + * 获取 DB 中的有效支付配置(全局默认),不存在返回 null + */ + private WechatPayConfigEntity getActiveDbConfig() { + try { + return wechatPayConfigService.getActiveConfig(null); + } catch (Exception e) { + logger.debug("获取DB支付配置失败,将回退到yml配置", e); + return null; + } + } + + /** + * 获取或创建 SDK 配置实例(无参版本,使用 DB 有效配置) + */ + private Config getOrCreateConfig() { + return getOrCreateConfig(getActiveDbConfig()); + } + + /** + * 获取或创建 SDK 配置实例(按商户号+证书+密钥+私钥路径缓存) + */ + private Config getOrCreateConfig(WechatPayConfigEntity cfg) { + String cacheKey = buildCacheKey(cfg); + Config cached = sdkConfigCache.get(cacheKey); + if (cached != null) { + return cached; + } + synchronized (this) { + cached = sdkConfigCache.get(cacheKey); + if (cached != null) { + return cached; + } + logger.info("初始化微信支付 RSAAutoCertificateConfig: mchId={}", getMchId(cfg)); + Config config = new RSAAutoCertificateConfig.Builder() + .merchantId(getMchId(cfg)) + .privateKeyFromPath(getPrivateKeyPath(cfg)) + .merchantSerialNumber(getCertSerialNo(cfg)) + .apiV3Key(getApiV3Key(cfg)) + .build(); + sdkConfigCache.put(cacheKey, config); + return config; + } + } + + private String buildCacheKey(WechatPayConfigEntity cfg) { + return getMchId(cfg) + "|" + getCertSerialNo(cfg) + "|" + getApiV3Key(cfg) + "|" + getPrivateKeyPath(cfg); + } + + private String getAppId(WechatPayConfigEntity cfg) { + return cfg != null && StringUtils.isNotBlank(cfg.getAppId()) ? cfg.getAppId() : wechatPayConfig.getAppId(); + } + + private String getMchId(WechatPayConfigEntity cfg) { + return cfg != null && StringUtils.isNotBlank(cfg.getMchId()) ? cfg.getMchId() : wechatPayConfig.getMchId(); + } + + private String getApiV3Key(WechatPayConfigEntity cfg) { + return cfg != null && StringUtils.isNotBlank(cfg.getApiV3Key()) ? cfg.getApiV3Key() : wechatPayConfig.getApiV3Key(); + } + + private String getCertSerialNo(WechatPayConfigEntity cfg) { + return cfg != null && StringUtils.isNotBlank(cfg.getCertSerialNo()) ? cfg.getCertSerialNo() : wechatPayConfig.getCertSerialNo(); + } + + private String getPrivateKeyPath(WechatPayConfigEntity cfg) { + return cfg != null && StringUtils.isNotBlank(cfg.getPrivateKeyPath()) ? cfg.getPrivateKeyPath() : wechatPayConfig.getPrivateKeyPath(); + } + + private String getNotifyUrl(WechatPayConfigEntity cfg) { + return cfg != null && StringUtils.isNotBlank(cfg.getNotifyUrl()) ? cfg.getNotifyUrl() : wechatPayConfig.getNotifyUrl(); + } + + // ==================== 工具方法 ==================== + + /** + * 元转分(BigDecimal -> int,四舍五入到分) + */ + private static int yuanToFen(BigDecimal yuan) { + return yuan.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP).intValue(); + } + + /** + * 分转元 + */ + private static BigDecimal fenToYuan(int fen) { + return new BigDecimal(fen).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP); + } +} diff --git a/src/main/java/io/as/modules/stats/controller/StatsController.java b/src/main/java/io/as/modules/stats/controller/StatsController.java new file mode 100644 index 0000000..340e762 --- /dev/null +++ b/src/main/java/io/as/modules/stats/controller/StatsController.java @@ -0,0 +1,79 @@ +package io.as.modules.stats.controller; + +import io.as.common.utils.R; +import io.as.modules.stats.service.StatsService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +/** + * 统计信息 Controller + */ +@Api(tags = "统计信息") +@RestController +@RequestMapping("/manage/stats") +public class StatsController { + + @Autowired + private StatsService statsService; + + /** + * 总览看板 + */ + @ApiOperation("总览看板") + @GetMapping("/overview") + public R overview() { + Map data = statsService.getOverview(); + Map result = new HashMap<>(); + result.put("overview", data); + return R.dataSuccess(result); + } + + /** + * 订单趋势统计 + */ + @ApiOperation("订单趋势统计") + @GetMapping("/orders") + public R orders( + @ApiParam(name = "startDate", value = "开始日期,格式 yyyy-MM-dd", required = true) + @RequestParam String startDate, + @ApiParam(name = "endDate", value = "结束日期,格式 yyyy-MM-dd", required = true) + @RequestParam String endDate) { + Map data = statsService.getOrderStats(startDate, endDate); + Map result = new HashMap<>(); + result.put("orderStats", data); + return R.dataSuccess(result); + } + + /** + * 库存统计 + */ + @ApiOperation("库存统计") + @GetMapping("/pool") + public R pool() { + Map data = statsService.getPoolStats(); + Map result = new HashMap<>(); + result.put("poolStats", data); + return R.dataSuccess(result); + } + + /** + * 渠道统计 + */ + @ApiOperation("渠道统计") + @GetMapping("/channels") + public R channels() { + Map data = statsService.getChannelStats(); + Map result = new HashMap<>(); + result.put("channelStats", data); + return R.dataSuccess(result); + } +} diff --git a/src/main/java/io/as/modules/stats/service/StatsService.java b/src/main/java/io/as/modules/stats/service/StatsService.java new file mode 100644 index 0000000..227e579 --- /dev/null +++ b/src/main/java/io/as/modules/stats/service/StatsService.java @@ -0,0 +1,41 @@ +package io.as.modules.stats.service; + +import java.util.Map; + +/** + * 统计信息 Service + */ +public interface StatsService { + + /** + * 总览看板 + * 返回:总订单数、今日订单数、总收入、今日收入、库存总量、可用库存、已使用卡数、激活失败卡数 + * + * @return 统计数据 + */ + Map getOverview(); + + /** + * 订单趋势统计(按日期分组的订单数和收入) + * + * @param startDate 开始日期,格式 yyyy-MM-dd + * @param endDate 结束日期,格式 yyyy-MM-dd + * @return 按日期分组的订单趋势数据 + */ + Map getOrderStats(String startDate, String endDate); + + /** + * 库存统计 + * 返回:按状态分组(UNUSED/RESERVED/USED)数量,按激活状态分组(PENDING/SUCCESS/FAILED)数量 + * + * @return 库存统计数据 + */ + Map getPoolStats(); + + /** + * 渠道统计(按 channel_org_id 分组的订单数和收入) + * + * @return 渠道统计数据 + */ + Map getChannelStats(); +} diff --git a/src/main/java/io/as/modules/stats/service/impl/StatsServiceImpl.java b/src/main/java/io/as/modules/stats/service/impl/StatsServiceImpl.java new file mode 100644 index 0000000..ab59387 --- /dev/null +++ b/src/main/java/io/as/modules/stats/service/impl/StatsServiceImpl.java @@ -0,0 +1,133 @@ +package io.as.modules.stats.service.impl; + +import io.as.modules.stats.service.StatsService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 统计信息 Service 实现 + */ +@Service("statsService") +public class StatsServiceImpl implements StatsService { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Override + public Map getOverview() { + Map result = new HashMap<>(); + + // 总订单数 + Integer totalOrders = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM day_card_order WHERE delete_flag='UNDEL'", + Integer.class); + result.put("totalOrders", totalOrders != null ? totalOrders : 0); + + // 今日订单数 + Integer todayOrders = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM day_card_order WHERE delete_flag='UNDEL' AND DATE(created_at)=CURDATE()", + Integer.class); + result.put("todayOrders", todayOrders != null ? todayOrders : 0); + + // 总收入 + BigDecimal totalRevenue = jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(pay_amount),0) FROM day_card_order WHERE delete_flag='UNDEL' AND status='PAID'", + BigDecimal.class); + result.put("totalRevenue", totalRevenue != null ? totalRevenue : BigDecimal.ZERO); + + // 今日收入 + BigDecimal todayRevenue = jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(pay_amount),0) FROM day_card_order WHERE delete_flag='UNDEL' AND status='PAID' AND DATE(pay_time)=CURDATE()", + BigDecimal.class); + result.put("todayRevenue", todayRevenue != null ? todayRevenue : BigDecimal.ZERO); + + // 库存总量 + Integer totalPool = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM day_card_pool WHERE delete_flag='UNDEL'", + Integer.class); + result.put("totalPool", totalPool != null ? totalPool : 0); + + // 可用库存 + Integer availablePool = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM day_card_pool WHERE delete_flag='UNDEL' AND status='UNUSED'", + Integer.class); + result.put("availablePool", availablePool != null ? availablePool : 0); + + // 已使用卡数 + Integer usedPool = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM day_card_pool WHERE delete_flag='UNDEL' AND status='USED'", + Integer.class); + result.put("usedPool", usedPool != null ? usedPool : 0); + + // 激活失败卡数 + Integer activateFailed = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM day_card_pool WHERE delete_flag='UNDEL' AND activate_status='FAILED'", + Integer.class); + result.put("activateFailed", activateFailed != null ? activateFailed : 0); + + return result; + } + + @Override + public Map getOrderStats(String startDate, String endDate) { + Map result = new HashMap<>(); + + List> list = jdbcTemplate.queryForList( + "SELECT DATE(created_at) as date, COUNT(1) as orderCount, " + + "COALESCE(SUM(CASE WHEN status='PAID' THEN pay_amount ELSE 0 END),0) as revenue " + + "FROM day_card_order WHERE delete_flag='UNDEL' " + + "AND created_at BETWEEN ? AND ? " + + "GROUP BY DATE(created_at) ORDER BY date", + startDate + " 00:00:00", endDate + " 23:59:59"); + + result.put("list", list); + return result; + } + + @Override + public Map getPoolStats() { + Map result = new HashMap<>(); + + // 按状态分组 + List> statusStats = jdbcTemplate.queryForList( + "SELECT status, COUNT(1) as count FROM day_card_pool " + + "WHERE delete_flag='UNDEL' GROUP BY status"); + Map statusMap = new HashMap<>(); + for (Map row : statusStats) { + statusMap.put(String.valueOf(row.get("status")), row.get("count")); + } + result.put("statusStats", statusMap); + + // 按激活状态分组 + List> activateStats = jdbcTemplate.queryForList( + "SELECT activate_status as activateStatus, COUNT(1) as count FROM day_card_pool " + + "WHERE delete_flag='UNDEL' GROUP BY activate_status"); + Map activateMap = new HashMap<>(); + for (Map row : activateStats) { + activateMap.put(String.valueOf(row.get("activateStatus")), row.get("count")); + } + result.put("activateStats", activateMap); + + return result; + } + + @Override + public Map getChannelStats() { + Map result = new HashMap<>(); + + List> list = jdbcTemplate.queryForList( + "SELECT COALESCE(o.channel_org_id, 0) as channelOrgId, COUNT(1) as orderCount, " + + "COALESCE(SUM(o.pay_amount),0) as revenue " + + "FROM day_card_order o WHERE o.delete_flag='UNDEL' " + + "GROUP BY o.channel_org_id"); + + result.put("list", list); + return result; + } +} diff --git a/src/main/java/io/as/modules/sys/constants/OrgCodeConstants.java b/src/main/java/io/as/modules/sys/constants/OrgCodeConstants.java new file mode 100755 index 0000000..e109c11 --- /dev/null +++ b/src/main/java/io/as/modules/sys/constants/OrgCodeConstants.java @@ -0,0 +1,95 @@ +package io.as.modules.sys.constants; + +import io.as.common.exception.ASException; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Random; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 组织编码与渠道专属前缀规则(PRD 1.4.1) + *
    + *
  • 组织编码(code):创建后不可变。渠道 = 渠道专属前缀 + 6位随机字符;代理商/门店 = 所属渠道的 activation_prefix + 6位随机
  • + *
  • 渠道专属前缀(activation_prefix):2位(字母数字排除 I、O、L、Z、0、1),全局唯一、创建时系统自动生成
  • + *
+ */ +public final class OrgCodeConstants { + + private OrgCodeConstants() { + } + + /** 渠道专属前缀允许字符(去掉 I、O、L、Z、0、1 以免混淆) */ + public static final String CHANNEL_PREFIX_ALLOWED_CHARS = "ABCDEFGHJKMNPQRSTUVWXY23456789"; + + /** 渠道前缀长度 */ + public static final int CHANNEL_PREFIX_LENGTH = 2; + + /** 组织编码中随机段长度 */ + public static final int ORG_CODE_RANDOM_SUFFIX_LENGTH = 6; + + /** 非渠道组织编码随机段字符集(大写字母+数字) */ + public static final String ORG_CODE_SUFFIX_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + /** 渠道组织编码固定前缀(历史兼容,新建渠道改用 渠道专属前缀+6位随机) */ + public static final String CHANNEL_CODE_PREFIX = "QD"; + + /** 禁止使用的 2 位前缀(易混淆组合,如 00、0O、OI 等) */ + public static final Set CHANNEL_PREFIX_BLACKLIST = Collections.unmodifiableSet( + Arrays.stream(new String[]{ + "00", "0O", "O0", "OI", "I0", "I1", "1I", "1L", "L1", "O1", "1O", + "0I", "IO", "L0", "0L", "ZZ", "11", "II", "LL", "OO" + }).collect(Collectors.toSet()) + ); + + /** + * 生成一个渠道专属前缀(2位大写,来自允许字符集且不在黑名单) + * 不保证全局唯一,调用方需查库校验后重试 + */ + public static String generateChannelPrefix() { + Random r = new Random(); + String allowed = CHANNEL_PREFIX_ALLOWED_CHARS; + String prefix; + do { + StringBuilder sb = new StringBuilder(2); + sb.append(allowed.charAt(r.nextInt(allowed.length()))); + sb.append(allowed.charAt(r.nextInt(allowed.length()))); + prefix = sb.toString(); + } while (CHANNEL_PREFIX_BLACKLIST.contains(prefix)); + return prefix.toUpperCase(); + } + + /** + * 生成渠道组织编码:6位随机字符(大写字母与数字混合),不再含渠道前缀。 + */ + public static String generateChannelOrgCode() { + Random r = new Random(); + StringBuilder sb = new StringBuilder(ORG_CODE_RANDOM_SUFFIX_LENGTH); + String allowed = ORG_CODE_SUFFIX_CHARS; + for (int i = 0; i < ORG_CODE_RANDOM_SUFFIX_LENGTH; i++) { + sb.append(allowed.charAt(r.nextInt(allowed.length()))); + } + return sb.toString(); + } + + /** + * 兼容旧调用,传入 channelPrefix 不再影响生成结果。 + */ + public static String generateChannelOrgCode(String channelPrefix) { + return generateChannelOrgCode(); + } + + /** + * 生成代理商/门店组织编码:6位随机(大写字母与数字混合) + */ + public static String generateOrgCodeWithChannelPrefix() { + Random r = new Random(); + StringBuilder sb = new StringBuilder(); + String allowed = ORG_CODE_SUFFIX_CHARS; + for (int i = 0; i < ORG_CODE_RANDOM_SUFFIX_LENGTH; i++) { + sb.append(allowed.charAt(r.nextInt(allowed.length()))); + } + return sb.toString(); + } +} diff --git a/src/main/java/io/as/modules/sys/constants/OrgTypeRoleConstants.java b/src/main/java/io/as/modules/sys/constants/OrgTypeRoleConstants.java new file mode 100644 index 0000000..f546a3e --- /dev/null +++ b/src/main/java/io/as/modules/sys/constants/OrgTypeRoleConstants.java @@ -0,0 +1,52 @@ +package io.as.modules.sys.constants; + +import org.apache.commons.lang.StringUtils; + +public final class OrgTypeRoleConstants { + private OrgTypeRoleConstants() {} + + public static final String ORG_CHANNEL = "CHANNEL"; + public static final String ORG_SERVICE_PROVIDER = "SERVICE_PROVIDER"; + public static final String ORG_STORE = "STORE"; + + public static final String ROLE_NAME_CHANNEL = "渠道"; + public static final String ROLE_NAME_AGENT = "代理商"; + public static final String ROLE_NAME_STORE = "门店"; + + public static final String API_ROLE_CHANNEL = "CHANNEL"; + public static final String API_ROLE_AGENT = "AGENT"; + public static final String API_ROLE_STORE = "STORE"; + + public static String roleNameForOrgType(String orgType) { + if (StringUtils.isBlank(orgType)) { + return null; + } + switch (orgType.trim()) { + case ORG_CHANNEL: + return ROLE_NAME_CHANNEL; + case ORG_SERVICE_PROVIDER: + return ROLE_NAME_AGENT; + case ORG_STORE: + return ROLE_NAME_STORE; + default: + return null; + } + } + + public static String apiRoleCodeForDbRoleName(String roleName) { + if (StringUtils.isBlank(roleName)) { + return null; + } + String t = roleName.trim(); + if (ROLE_NAME_CHANNEL.equals(t)) { + return API_ROLE_CHANNEL; + } + if (ROLE_NAME_AGENT.equals(t)) { + return API_ROLE_AGENT; + } + if (ROLE_NAME_STORE.equals(t)) { + return API_ROLE_STORE; + } + return null; + } +} diff --git a/src/main/java/io/as/modules/sys/controller/AbstractController.java b/src/main/java/io/as/modules/sys/controller/AbstractController.java new file mode 100644 index 0000000..832fc4d --- /dev/null +++ b/src/main/java/io/as/modules/sys/controller/AbstractController.java @@ -0,0 +1,18 @@ +package io.as.modules.sys.controller; + +import io.as.modules.sys.entity.SysUserEntity; +import org.apache.shiro.SecurityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public abstract class AbstractController { + protected Logger logger = LoggerFactory.getLogger(getClass()); + + protected SysUserEntity getUser() { + return (SysUserEntity) SecurityUtils.getSubject().getPrincipal(); + } + + protected Long getUserId() { + return getUser().getId(); + } +} diff --git a/src/main/java/io/as/modules/sys/controller/ContractController.java b/src/main/java/io/as/modules/sys/controller/ContractController.java new file mode 100644 index 0000000..a3f9b64 --- /dev/null +++ b/src/main/java/io/as/modules/sys/controller/ContractController.java @@ -0,0 +1,84 @@ +package io.as.modules.sys.controller; + +import io.as.common.annotation.AuditAction; +import io.as.common.utils.R; +import io.as.common.validator.ValidatorUtils; +import io.as.modules.sys.dao.ContractActivationCodeDao; +import io.as.modules.sys.entity.ContractActivationCodeEntity; +import io.as.modules.sys.form.ByStoreRequest; +import io.as.modules.sys.form.ContractActivationCodeRequest; +import io.as.modules.sys.form.UpdateMaxDeviceCountRequest; +import io.as.modules.sys.service.SysOrgService; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 合同与权益激活码(门店与合同 1:1) + */ +@Api(tags = "合同与权益激活码") +@RestController +@RequestMapping("/manage/contract") +public class ContractController extends AbstractController { + + @Autowired + private ContractActivationCodeDao contractActivationCodeDao; + + @Autowired + private SysOrgService sysOrgService; + + @ApiOperation(value = "按门店查权益激活码列表", notes = "根据门店组织ID查询该门店下权益激活码列表") + @PostMapping("/by-store") + public R getByStore(@RequestBody ByStoreRequest request) { + ValidatorUtils.validateEntity(request); + Long orgId = request.getOrgId(); + if (!sysOrgService.isOrgInScope(orgId, getUserId())) { + return R.error(403, "无权限查看该门店合同"); + } + List codes = contractActivationCodeDao.selectList( + new LambdaQueryWrapper() + .eq(ContractActivationCodeEntity::getStoreOrgId, orgId) + .eq(ContractActivationCodeEntity::getDeleteFlag, "UNDEL")); + List> list = codes.stream().map(cac -> { + Map m = new HashMap<>(); + m.put("id", cac.getId()); + m.put("contractId", cac.getContractId()); + m.put("code", cac.getCode()); + m.put("cardType", cac.getCardType()); + m.put("maxDeviceCount", 0); + m.put("status", cac.getStatus()); + return m; + }).collect(Collectors.toList()); + Map data = new HashMap<>(); + data.put("list", list); + return R.dataSuccess(data); + } + + @ApiOperation(value = "重新生成权益激活码", notes = "根据激活码记录ID重新生成 code") + @AuditAction(value = "ACTIVATION_CODE_REGENERATE", description = "重新生成权益激活码") + @PostMapping("/activation-code/{cacId}/regenerate-code") + public R regenerateCode(@PathVariable("cacId") Long cacId) { + return R.error("当前版本不支持此操作"); + } + + @ApiOperation(value = "修改权益激活码剩余可激活数量", notes = "maxDeviceCount=剩余可激活设备的数量") + @AuditAction(value = "ACTIVATION_CODE_UPDATE_COUNT", description = "修改权益激活码设备上限") + @PutMapping("/activation-code/{orgId}/max-device-count") + public R updateMaxDeviceCount(@PathVariable("orgId") String orgIdStr, @RequestBody UpdateMaxDeviceCountRequest request) { + return R.error("当前版本不支持此操作"); + } + + @ApiOperation(value = "设置权益激活码", notes = "路径传门店组织ID(orgId)") + @AuditAction(value = "ACTIVATION_CODE_SET", description = "设置权益激活码") + @PostMapping("/{orgId}/activation-code") + public R setActivationCode(@PathVariable("orgId") String orgIdStr, @RequestBody ContractActivationCodeRequest request) { + return R.error("当前版本不支持此操作"); + } +} diff --git a/src/main/java/io/as/modules/sys/controller/StoreController.java b/src/main/java/io/as/modules/sys/controller/StoreController.java new file mode 100644 index 0000000..d68e704 --- /dev/null +++ b/src/main/java/io/as/modules/sys/controller/StoreController.java @@ -0,0 +1,77 @@ +package io.as.modules.sys.controller; + +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.modules.sys.constants.OrgTypeRoleConstants; +import io.as.modules.sys.entity.SysOrgEntity; +import io.as.modules.sys.service.SysOrgService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +/** + * 门店专用接口:门店搜索、查看门店绑定卡等 + */ +@Api(tags = "门店管理") +@RestController +@RequestMapping("/manage/store") +public class StoreController extends AbstractController { + + @Autowired + private SysOrgService sysOrgService; + + @ApiOperation(value = "门店列表", notes = "返回当前用户数据范围内全部 STORE 组织") + @GetMapping("/list") + public R list(@RequestParam Map params) { + List list = sysOrgService.listStoresInScope(params, getUserId()); + Map data = new HashMap<>(); + data.put("list", list); + return R.dataSuccess(data); + } + + @ApiOperation(value = "按代理商列门店", notes = "返回指定代理商组织子树内全部门店") + @GetMapping("/list-by-agent/{agentOrgId}") + public R listByAgent(@PathVariable("agentOrgId") Long agentOrgId, @RequestParam Map params) { + if (!sysOrgService.isOrgInScope(agentOrgId, getUserId())) { + return R.error(403, "无权限查看该组织"); + } + SysOrgEntity org = sysOrgService.getById(agentOrgId); + if (org == null || !OrgTypeRoleConstants.ORG_SERVICE_PROVIDER.equals(org.getType())) { + return R.error(400, "组织不存在或不是代理商"); + } + Map p = new HashMap<>(params); + p.put("orgId", agentOrgId); + List list = sysOrgService.listStoresInScope(p, getUserId()); + Map data = new HashMap<>(); + data.put("list", list); + return R.dataSuccess(data); + } + + @ApiOperation(value = "门店搜索", notes = "分页搜索门店") + @GetMapping("/search") + public R search(@RequestParam Map params) { + PageUtils page = sysOrgService.queryStoreSearchPage(params, getUserId()); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + @ApiOperation(value = "门店绑定卡列表", notes = "按门店ID分页查询该门店绑定的卡") + @GetMapping("/{storeOrgId}/cards") + public R cards(@PathVariable("storeOrgId") Long storeOrgId, @RequestParam Map params) { + if (!sysOrgService.isOrgInScope(storeOrgId, getUserId())) { + return R.error("无权限查看该门店"); + } + SysOrgEntity org = sysOrgService.getById(storeOrgId); + if (org == null || !"STORE".equals(org.getType())) { + return R.error("门店不存在"); + } + Map data = new HashMap<>(); + data.put("page", new PageUtils(Collections.emptyList(), 0, 10, 1)); + return R.dataSuccess(data); + } +} diff --git a/src/main/java/io/as/modules/sys/controller/SysLoginController.java b/src/main/java/io/as/modules/sys/controller/SysLoginController.java new file mode 100644 index 0000000..103ae30 --- /dev/null +++ b/src/main/java/io/as/modules/sys/controller/SysLoginController.java @@ -0,0 +1,70 @@ +package io.as.modules.sys.controller; + +import io.as.common.utils.R; +import io.as.modules.sys.entity.SysUserEntity; +import io.as.modules.sys.form.SysLoginForm; +import io.as.modules.sys.service.SysCaptchaService; +import io.as.modules.sys.service.SysUserService; +import io.as.modules.sys.service.SysUserTokenService; +import org.apache.commons.io.IOUtils; +import org.apache.shiro.crypto.hash.Sha256Hash; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import javax.imageio.ImageIO; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletResponse; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.Map; + +@RestController +public class SysLoginController extends AbstractController { + @Autowired + private SysUserService sysUserService; + @Autowired + private SysUserTokenService sysUserTokenService; + @Autowired + private SysCaptchaService sysCaptchaService; + + @GetMapping("captcha.jpg") + public void captcha(HttpServletResponse response, String uuid) throws IOException { + response.setHeader("Cache-Control", "no-store, no-cache"); + response.setContentType("image/jpeg"); + BufferedImage image = sysCaptchaService.getCaptcha(uuid); + ServletOutputStream out = response.getOutputStream(); + ImageIO.write(image, "jpg", out); + IOUtils.closeQuietly(out); + } + + @PostMapping("/sys/login") + public Map login(@RequestBody SysLoginForm form) throws IOException { + boolean captcha = sysCaptchaService.validate(form.getUuid(), form.getCaptcha()); + if (!captcha) { + return R.error("验证码不正确"); + } + SysUserEntity user = sysUserService.queryByUserName(form.getUsername()); + if (user == null || !user.getPassword().equals(new Sha256Hash(form.getPassword(), user.getSalt()).toHex())) { + return R.error("账号或密码不正确"); + } + if (user.getStatus() == 0) { + return R.error("账号已被锁定,请联系管理员"); + } + return sysUserTokenService.createToken(user.getId()); + } + + @PostMapping("/sys/refresh") + public Map refresh(@RequestBody Map body) { + String refreshToken = body != null ? body.get("refreshToken") : null; + return sysUserTokenService.refreshToken(refreshToken); + } + + @PostMapping("/sys/logout") + public R logout() { + sysUserTokenService.logout(getUserId()); + return R.ok(); + } +} diff --git a/src/main/java/io/as/modules/sys/controller/SysMenuController.java b/src/main/java/io/as/modules/sys/controller/SysMenuController.java new file mode 100644 index 0000000..f7dd645 --- /dev/null +++ b/src/main/java/io/as/modules/sys/controller/SysMenuController.java @@ -0,0 +1,135 @@ +package io.as.modules.sys.controller; + +import io.as.common.annotation.SysLog; +import io.as.common.exception.ASException; +import io.as.common.utils.Constant; +import io.as.common.utils.R; +import io.as.modules.sys.entity.SysMenuEntity; +import io.as.modules.sys.service.ShiroService; +import io.as.modules.sys.service.SysMenuService; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +@RestController +@RequestMapping("/manage/menu") +public class SysMenuController extends AbstractController { + @Autowired + private SysMenuService sysMenuService; + @Autowired + private ShiroService shiroService; + + @GetMapping("/nav") + public R nav() { + List menuList = sysMenuService.getUserMenuList(getUserId()); + Set permissions = shiroService.getUserPermissions(getUserId()); + Map data = new HashMap<>(); + data.put("menuList", menuList); + data.put("permissions", permissions); + return R.dataSuccess(data); + } + + @GetMapping("/list") + public R list() { + List menuList = sysMenuService.list(); + Collections.sort(menuList); + HashMap menuMap = new HashMap<>(12); + for (SysMenuEntity s : menuList) { + menuMap.put(s.getId(), s); + } + for (SysMenuEntity s : menuList) { + SysMenuEntity parent = menuMap.get(s.getParentId()); + if (Objects.nonNull(parent)) { + s.setParentName(parent.getName()); + } + } + HashMap data = new HashMap<>(); + data.put("menuList", menuList); + return R.dataSuccess(data); + } + + @GetMapping("/select") + public R select() { + List menuList = sysMenuService.queryNotButtonList(); + SysMenuEntity root = new SysMenuEntity(); + root.setId(0L); + root.setName("一级菜单"); + root.setParentId(-1L); + root.setOpen(true); + menuList.add(root); + Map data = new HashMap<>(); + data.put("menuList", menuList); + return R.dataSuccess(data); + } + + @GetMapping("/info/{menuId}") + public R info(@PathVariable("menuId") Long menuId) { + SysMenuEntity menu = sysMenuService.getById(menuId); + Map data = new HashMap<>(); + data.put("menu", menu); + return R.dataSuccess(data); + } + + @SysLog("保存菜单") + @PostMapping("/save") + public R save(@RequestBody SysMenuEntity menu) { + verifyForm(menu); + sysMenuService.save(menu); + return R.ok(); + } + + @SysLog("修改菜单") + @PostMapping("/update") + public R update(@RequestBody SysMenuEntity menu) { + verifyForm(menu); + sysMenuService.updateById(menu); + return R.ok(); + } + + @SysLog("删除菜单") + @PostMapping("/delete/{menuId}") + public R delete(@PathVariable("menuId") long menuId) { + if (menuId <= 31) { + return R.error("系统菜单,不能删除"); + } + List menuList = sysMenuService.queryListParentId(menuId); + if (menuList.size() > 0) { + return R.error("请先删除子菜单或按钮"); + } + sysMenuService.delete(menuId); + return R.ok(); + } + + private void verifyForm(SysMenuEntity menu) { + if (StringUtils.isBlank(menu.getName())) { + throw new ASException("菜单名称不能为空"); + } + if (menu.getParentId() == null) { + throw new ASException("上级菜单不能为空"); + } + if (menu.getType() == Constant.MenuType.MENU.getValue()) { + if (StringUtils.isBlank(menu.getUrl())) { + throw new ASException("菜单URL不能为空"); + } + } + int parentType = Constant.MenuType.CATALOG.getValue(); + if (menu.getParentId() != 0) { + SysMenuEntity parentMenu = sysMenuService.getById(menu.getParentId()); + parentType = parentMenu.getType(); + } + if (menu.getType() == Constant.MenuType.CATALOG.getValue() || + menu.getType() == Constant.MenuType.MENU.getValue()) { + if (parentType != Constant.MenuType.CATALOG.getValue()) { + throw new ASException("上级菜单只能为目录类型"); + } + return; + } + if (menu.getType() == Constant.MenuType.BUTTON.getValue()) { + if (parentType != Constant.MenuType.MENU.getValue()) { + throw new ASException("上级菜单只能为菜单类型"); + } + } + } +} diff --git a/src/main/java/io/as/modules/sys/controller/SysOrgController.java b/src/main/java/io/as/modules/sys/controller/SysOrgController.java new file mode 100644 index 0000000..65d7fc0 --- /dev/null +++ b/src/main/java/io/as/modules/sys/controller/SysOrgController.java @@ -0,0 +1,273 @@ +package io.as.modules.sys.controller; + +import io.as.common.annotation.AuditAction; +import io.as.common.utils.Constant; +import io.as.common.utils.R; +import io.as.common.validator.ValidatorUtils; +import io.as.modules.sys.constants.OrgTypeRoleConstants; +import io.as.modules.sys.entity.SysOrgEntity; +import io.as.modules.sys.entity.SysUserEntity; +import io.as.modules.sys.service.SysOrgService; +import io.as.modules.sys.service.StoreProfileService; +import io.as.modules.sys.service.SysUserService; +import io.as.modules.sys.vo.CardTypeDetailStatsVo; +import io.as.modules.sys.vo.OrgCardTypeAllocatableVo; +import io.as.modules.sys.vo.OrgCardStatsByTypeVo; +import io.as.modules.sys.vo.OrgCardStatsVo; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import org.apache.commons.lang.StringUtils; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 组织管理 + */ +@Api(tags = "组织管理") +@RestController +@RequestMapping("/manage/org") +public class SysOrgController extends AbstractController { + @Autowired + private SysOrgService sysOrgService; + + @Autowired + private StoreProfileService storeProfileService; + + @Autowired + private SysUserService sysUserService; + + /** + * 组织列表:从渠道开始的树形(渠道→代理商),不含平台、不含门店 + */ + @ApiOperation(value = "组织列表", notes = "不含门店;超级管理员或平台账号(orgId 为空)返回平台→渠道→代理商,其余按数据范围;默认仅 ACTIVE") + @GetMapping("/list") + public R list(@RequestParam Map params) { + List tree = sysOrgService.queryOrgTreeExcludeStore(getUserId()); + Map data = new HashMap<>(); + data.put("list", tree); + return R.dataSuccess(data); + } + + /** + * 组织树(下拉/树形选择):同 list 结构,从渠道开始,不含平台、不含门店;默认不按 status 过滤,传 status 则只返回该状态 + */ + @ApiOperation(value = "组织树", notes = "用于下拉/树形选择;默认返回全部状态;可选 status 筛选组织状态(代理商登录忽略 status);超级管理员或平台账号(orgId 为空)顶层为平台、其下渠道与代理商;代理商账号仅返回代理商子树(不含门店节点)") + @GetMapping("/tree") + public R tree(@ApiParam(value = "可选,组织状态;不传则不过滤 status", required = false) @RequestParam(required = false) String status) { + String statusFilter = StringUtils.isNotBlank(status) ? status.trim() : null; + List tree = sysOrgService.queryOrgTreeExcludeStore(getUserId(), statusFilter); + Map data = new HashMap<>(); + data.put("list", tree); + return R.dataSuccess(data); + } + + + /** + * 组织树(含门店):用于下拉选择门店等场景;返回全部类型组织树(含 STORE),仅启用状态 + */ + @ApiOperation(value = "组织树(含门店)", notes = "全类型组织树,含门店节点;用于门店选择器等") + @GetMapping("/all-tree") + public R allTree() { + List tree = sysOrgService.queryOrgTree(getUserId()); + Map data = new HashMap<>(); + data.put("list", tree); + return R.dataSuccess(data); + } + + /** + * 组织列表(按数据范围):仅返回该账号下的组织;平台账号(超级管理员或 orgId 为空)返回全部,否则返回所属组织及其子级;未删除、启用状态,扁平列表含 parentOrgName/parentOrgCode + */ + @ApiOperation(value = "组织列表(数据范围)", notes = "按当前用户数据范围返回扁平组织列表") + @GetMapping("/list-data") + public R listData() { + List list = sysOrgService.listOrgByScope(getUserId()); + Map data = new HashMap<>(1); + data.put("list", list); + return R.dataSuccess(data); + } + + /** + * 当前数据范围内全部渠道(扁平) + */ + @ApiOperation(value = "渠道列表(扁平)", notes = "当前用户数据范围内全部渠道(仅 CHANNEL、ACTIVE);用于下拉等") + @GetMapping("/channels") + public R listChannels() { + List list = sysOrgService.listChannelsInScope(getUserId()); + Map data = new HashMap<>(); + data.put("list", list); + return R.dataSuccess(data); + } + + /** + * 指定渠道下全部代理商(多级)树形,不含门店 + */ + @ApiOperation(value = "渠道下代理商树", notes = "指定渠道下全部代理商(含子代理商)树形结构,不含门店;channelId 须为渠道组织且在数据范围内") + @GetMapping("/channel/{channelId}/agent-tree") + public R agentTreeUnderChannel( + @ApiParam(value = "渠道组织ID", required = true) @PathVariable("channelId") Long channelId) { + if (!sysOrgService.isOrgInScope(channelId, getUserId())) { + return R.error(403, "无权限查看该渠道"); + } + SysOrgEntity ch = sysOrgService.getById(channelId); + if (ch == null || !OrgTypeRoleConstants.ORG_CHANNEL.equals(ch.getType())) { + return R.error(400, "组织不存在或不是渠道"); + } + List list = sysOrgService.queryAgentTreeUnderChannel(channelId, getUserId()); + Map data = new HashMap<>(); + data.put("list", list); + return R.dataSuccess(data); + } + + /** + * 组织卡统计:可分配为当前组织持仓(仅 card.orgId=orgId);已激活、待激活含本组织及子级。orgId=1 为全平台特殊口径。 + */ + @ApiOperation(value = "组织卡统计", notes = "可分配仅本 org_id;已激活/待激活含子级。orgId=1 为全平台。按卡类型分类") + @GetMapping("/{orgId}/card-stats") + public R cardStats(@ApiParam(value = "组织ID", required = true) @PathVariable("orgId") Long orgId) { + OrgCardStatsByTypeVo stats; + if (orgId == 1) { + // 如果传入的ID是1,直接返回所有非删除数据的统计 + stats = sysOrgService.getAllCardStatsWithType(); + } else { + stats = sysOrgService.getCardStatsByOrgIdWithType(orgId, getUserId()); + if (stats == null) { + return R.error(403, "无权限查看该组织"); + } + } + Map data = new HashMap<>(); + data.put("total", stats.getTotal()); + data.put("byType", stats.getByType()); + return R.dataSuccess(data); + } + + /** + * 按卡类型(年卡/月卡/天卡)统计:采购总量、预置与激活码链路、异常与可用库存;可选按组织及下级分级筛选 + */ + @ApiOperation(value = "年/月/天卡明细统计", notes = "可选 orgId:不传时按登录账号解析(与 card-overview 一致:超管或平台组织账号为全平台,否则为本人所属组织及子级);显式传 1 为全平台;传其他组织 ID 为该组织及其下级范围内统计。无权限返回 403。返回各类型:累计采购总量、预置绑定数量、预置绑定已激活数量、激活码分配数量、激活码激活数量、激活异常数量、待迁移数量、可用数量") + @GetMapping("/card-type-detail-stats") + public R cardTypeDetailStats( + @ApiParam(value = "组织ID;不传按登录账号解析;传 1 为全平台", required = false) @RequestParam(required = false) Long orgId) { + List list = sysOrgService.getCardDetailStatsYearMonthDay(orgId, getUserId()); + if (list == null) { + return R.error(403, "无权限查看该组织"); + } + Map data = new HashMap<>(); + data.put("list", list); + return R.dataSuccess(data); + } + + /** + * 按卡类型返回当前组织可分配卡数量(IN_STOCK + UNUSED);平台账号为全渠道 + */ + @ApiOperation(value = "组织卡概览", notes = "按卡类型返回可分配数量(库存中且未使用、未过期)。不传 orgId:超管/平台组织为全平台所有渠道,否则为登录账号所属组织及子级。传 orgId=1 为全平台;传其他 ID 为该组织及子级。可选 storeOrgId 或 storeId(门店组织 ID):剩余可用量按该门店所属渠道在库可分配量统计。无权限返回 403") + @GetMapping("/card-overview") + public R cardOverview( + @ApiParam(value = "组织ID;不传则按登录账号解析(平台为全渠道,否则为本人组织及子级)") @RequestParam(required = false) Long orgId, + @ApiParam(value = "门店组织ID;与 storeId 二选一,优先本参数") @RequestParam(required = false) Long storeOrgId, + @ApiParam(value = "门店组织ID;与 storeOrgId 二选一") @RequestParam(required = false) Long storeId) { + Long effectiveStoreOrgId = storeOrgId != null ? storeOrgId : storeId; + List byType = sysOrgService.getCardAllocatableOverviewByType(orgId, effectiveStoreOrgId, getUserId()); + if (byType == null) { + return R.error(403, "无权限查看该组织"); + } + Map data = new HashMap<>(); + data.put("byType", byType); + return R.dataSuccess(data); + } + + /** + * 根据组织ID,返回该组织所属渠道下的组织树(渠道→代理商→门店;children 嵌套;受当前登录账号数据范围限制) + */ + @ApiOperation(value = "同渠道组织树", notes = "根据组织ID返回其所属渠道下的树形组织,含门店") + @GetMapping("/{orgId}/same-channel-orgs") + public R listSameChannelOrgs(@ApiParam(value = "组织ID", required = true) @PathVariable("orgId") Long orgId) { + if (!sysOrgService.isOrgInScope(orgId, getUserId())) { + return R.error(403, "无权限查看该组织"); + } + List tree = sysOrgService.listOrgsInSameChannel(orgId, getUserId()); + Map data = new HashMap<>(); + data.put("list", tree); + return R.dataSuccess(data); + } + + /** + * 组织信息 + */ + @ApiOperation(value = "组织详情", notes = "根据组织ID查询组织详情,含门店扩展、登录账号等") + @GetMapping("/info/{id}") + public R info(@ApiParam(value = "组织ID", required = true) @PathVariable("id") Long id) { + if (!sysOrgService.isOrgInScope(id, getUserId())) { + return R.error("无权限查看该组织"); + } + SysOrgEntity org = sysOrgService.getById(id); + if (org != null && org.getParentId() != null) { + SysOrgEntity parent = sysOrgService.getById(org.getParentId()); + if (parent != null) { + org.setParentOrgName(parent.getName()); + org.setParentOrgCode(parent.getCode()); + } + } + if (org != null && "STORE".equals(org.getType())) { + org.setStoreProfile(storeProfileService.getById(id)); + } + // 该组织下登录账号的 username 填到 loginMobile 返回 + if (org != null) { + String loginMobile = sysUserService.lambdaQuery() + .eq(SysUserEntity::getOrgId, id) + .list() + .stream() + .map(SysUserEntity::getUsername) + .collect(Collectors.joining(",")); + org.setLoginMobile(loginMobile.isEmpty() ? null : loginMobile); + } + Map data = new HashMap<>(); + data.put("org", org); + return R.dataSuccess(data); + } + + /** + * 保存组织 + */ + @ApiOperation(value = "保存组织", notes = "新增组织;type=SERVICE_PROVIDER(代理商)时可传 developmentStaff、contactPhone、servicePartner、wechatQrImageUrl") + @AuditAction(value = "ORG_SAVE", description = "保存组织") + @PostMapping("/save") + public R save(@RequestBody SysOrgEntity org) { + ValidatorUtils.validateEntity(org); + sysOrgService.saveOrg(org, getUserId()); + // 返回保存后的组织(含 id、orgCode 等),便于前端拿到渠道组织编码等 + Map data = new HashMap<>(); + data.put("org", org); + return R.dataSuccess(data); + } + + /** + * 修改组织 + */ + @ApiOperation(value = "修改组织", notes = "更新组织信息;代理商可维护 developmentStaff、contactPhone、servicePartner、wechatQrImageUrl") + @AuditAction(value = "ORG_UPDATE", description = "修改组织") + @PostMapping("/update") + public R update(@RequestBody SysOrgEntity org) { + ValidatorUtils.validateEntity(org); + sysOrgService.updateOrg(org, getUserId()); + return R.ok(); + } + + /** + * 删除组织(软删除) + */ + @ApiOperation(value = "删除组织", notes = "批量软删除组织") + @AuditAction(value = "ORG_DELETE", description = "删除组织") + @PostMapping("/delete") + public R delete(@RequestBody Long[] ids) { + sysOrgService.deleteBatch(ids, getUserId()); + return R.ok(); + } +} diff --git a/src/main/java/io/as/modules/sys/controller/SysRoleController.java b/src/main/java/io/as/modules/sys/controller/SysRoleController.java new file mode 100644 index 0000000..c15751c --- /dev/null +++ b/src/main/java/io/as/modules/sys/controller/SysRoleController.java @@ -0,0 +1,77 @@ +package io.as.modules.sys.controller; + +import io.as.common.annotation.AuditAction; +import io.as.common.annotation.SysLog; +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.common.validator.ValidatorUtils; +import io.as.modules.sys.entity.SysRoleEntity; +import io.as.modules.sys.service.SysRoleMenuService; +import io.as.modules.sys.service.SysRoleService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/manage/role") +public class SysRoleController extends AbstractController { + @Autowired + private SysRoleService sysRoleService; + @Autowired + private SysRoleMenuService sysRoleMenuService; + + @GetMapping("/list") + public R list(@RequestParam Map params) { + PageUtils page = sysRoleService.queryPage(params); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + @GetMapping("/select") + public R select() { + List list = sysRoleService.list(); + Map data = new HashMap<>(); + data.put("list", list); + return R.dataSuccess(data); + } + + @GetMapping("/info/{roleId}") + public R info(@PathVariable("roleId") Long roleId) { + SysRoleEntity role = sysRoleService.getById(roleId); + List menuIdList = sysRoleMenuService.queryMenuIdList(roleId); + role.setMenuIdList(menuIdList); + Map data = new HashMap<>(); + data.put("role", role); + return R.dataSuccess(data); + } + + @SysLog("保存角色") + @AuditAction(value = "ROLE_SAVE", description = "保存角色") + @PostMapping("/save") + public R save(@RequestBody SysRoleEntity role) { + ValidatorUtils.validateEntity(role); + sysRoleService.saveRole(role, getUserId()); + return R.ok(); + } + + @SysLog("修改角色") + @AuditAction(value = "ROLE_UPDATE", description = "修改角色") + @PostMapping("/update") + public R update(@RequestBody SysRoleEntity role) { + ValidatorUtils.validateEntity(role); + sysRoleService.update(role, getUserId()); + return R.ok(); + } + + @SysLog("删除角色") + @AuditAction(value = "ROLE_DELETE", description = "删除角色") + @PostMapping("/delete") + public R delete(@RequestBody Long[] roleIds) { + sysRoleService.deleteBatch(roleIds); + return R.ok(); + } +} diff --git a/src/main/java/io/as/modules/sys/controller/SysUserController.java b/src/main/java/io/as/modules/sys/controller/SysUserController.java new file mode 100644 index 0000000..0588a12 --- /dev/null +++ b/src/main/java/io/as/modules/sys/controller/SysUserController.java @@ -0,0 +1,130 @@ +package io.as.modules.sys.controller; + +import io.as.common.annotation.AuditAction; +import io.as.common.annotation.SysLog; +import io.as.common.utils.Constant; +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.common.validator.Assert; +import io.as.common.validator.ValidatorUtils; +import io.as.common.validator.group.AddGroup; +import io.as.common.validator.group.UpdateGroup; +import io.as.modules.sys.constants.OrgTypeRoleConstants; +import io.as.modules.sys.entity.SysRoleEntity; +import io.as.modules.sys.entity.SysUserEntity; +import io.as.modules.sys.form.PasswordForm; +import io.as.modules.sys.service.SysRoleService; +import io.as.modules.sys.service.SysUserRoleService; +import io.as.modules.sys.service.SysUserService; +import org.apache.commons.lang.ArrayUtils; +import org.apache.shiro.crypto.hash.Sha256Hash; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/manage/user") +public class SysUserController extends AbstractController { + @Autowired + private SysUserService sysUserService; + @Autowired + private SysUserRoleService sysUserRoleService; + @Autowired + private SysRoleService sysRoleService; + + private static final String ROLE_NAME_PLATFORM = "PLATFORM"; + + @GetMapping("/list") + public R list(@RequestParam Map params) { + PageUtils page = sysUserService.queryPage(params); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + @GetMapping("/info") + public R info() { + SysUserEntity u = getUser(); + Map data = new HashMap<>(); + data.put("user", u); + data.put("roleName", queryRoleNameByUserId(getUserId())); + return R.ok().put("data", data); + } + + private String queryRoleNameByUserId(long userId) { + SysUserEntity user = sysUserService.getById(userId); + if (user != null && Constant.isSuperAdmin(user.getOrgId())) { + return ROLE_NAME_PLATFORM; + } + List roleIdList = sysUserRoleService.queryRoleIdList(userId); + if (roleIdList == null || roleIdList.isEmpty()) { + return null; + } + Long roleId = roleIdList.get(0); + SysRoleEntity role = sysRoleService.getById(roleId); + if (role == null || role.getRoleName() == null) { + return null; + } + String orgMapped = OrgTypeRoleConstants.apiRoleCodeForDbRoleName(role.getRoleName()); + return orgMapped != null ? orgMapped : role.getRoleName(); + } + + @SysLog("修改密码") + @AuditAction(value = "USER_PASSWORD", description = "修改密码") + @PostMapping("/password") + public R password(@RequestBody PasswordForm form) { + Assert.isBlank(form.getNewPassword(), "新密码不为能空"); + String password = new Sha256Hash(form.getPassword(), getUser().getSalt()).toHex(); + String newPassword = new Sha256Hash(form.getNewPassword(), getUser().getSalt()).toHex(); + boolean flag = sysUserService.updatePassword(getUserId(), password, newPassword); + if (!flag) { + return R.error("原密码不正确"); + } + return R.ok(); + } + + @GetMapping("/info/{userId}") + public R info(@PathVariable("userId") Long userId) { + SysUserEntity user = sysUserService.getById(userId); + List roleIdList = sysUserRoleService.queryRoleIdList(userId); + user.setRoleIdList(roleIdList); + Map data = new HashMap<>(); + data.put("user", user); + return R.dataSuccess(data); + } + + @SysLog("保存用户") + @AuditAction(value = "USER_SAVE", description = "保存用户") + @PostMapping("/save") + public R save(@RequestBody SysUserEntity user) { + ValidatorUtils.validateEntity(user, AddGroup.class); + sysUserService.saveUser(user, getUserId()); + return R.ok(); + } + + @SysLog("修改用户") + @AuditAction(value = "USER_UPDATE", description = "修改用户") + @PostMapping("/update") + public R update(@RequestBody SysUserEntity user) { + ValidatorUtils.validateEntity(user, UpdateGroup.class); + sysUserService.update(user, getUserId()); + return R.ok(); + } + + @SysLog("删除用户") + @AuditAction(value = "USER_DELETE", description = "删除用户") + @PostMapping("/delete") + public R delete(@RequestBody Long[] userIds) { + if (ArrayUtils.contains(userIds, 1L)) { + return R.error("系统管理员不能删除"); + } + if (ArrayUtils.contains(userIds, getUserId())) { + return R.error("当前用户不能删除"); + } + sysUserService.deleteBatch(userIds); + return R.ok(); + } +} diff --git a/src/main/java/io/as/modules/sys/dao/AuditLogDao.java b/src/main/java/io/as/modules/sys/dao/AuditLogDao.java new file mode 100755 index 0000000..6e0f5a3 --- /dev/null +++ b/src/main/java/io/as/modules/sys/dao/AuditLogDao.java @@ -0,0 +1,12 @@ +package io.as.modules.sys.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.sys.entity.AuditLogEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 审计日志 + */ +@Mapper +public interface AuditLogDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/sys/dao/CardDao.java b/src/main/java/io/as/modules/sys/dao/CardDao.java new file mode 100644 index 0000000..b58c377 --- /dev/null +++ b/src/main/java/io/as/modules/sys/dao/CardDao.java @@ -0,0 +1,9 @@ +package io.as.modules.sys.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.sys.entity.CardEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CardDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/sys/dao/CardTransferDao.java b/src/main/java/io/as/modules/sys/dao/CardTransferDao.java new file mode 100644 index 0000000..c9d0f87 --- /dev/null +++ b/src/main/java/io/as/modules/sys/dao/CardTransferDao.java @@ -0,0 +1,9 @@ +package io.as.modules.sys.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.sys.entity.CardTransferEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CardTransferDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/sys/dao/ContractActivationCodeDao.java b/src/main/java/io/as/modules/sys/dao/ContractActivationCodeDao.java new file mode 100644 index 0000000..6051ece --- /dev/null +++ b/src/main/java/io/as/modules/sys/dao/ContractActivationCodeDao.java @@ -0,0 +1,9 @@ +package io.as.modules.sys.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.sys.entity.ContractActivationCodeEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContractActivationCodeDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/sys/dao/ContractDao.java b/src/main/java/io/as/modules/sys/dao/ContractDao.java new file mode 100644 index 0000000..4628793 --- /dev/null +++ b/src/main/java/io/as/modules/sys/dao/ContractDao.java @@ -0,0 +1,9 @@ +package io.as.modules.sys.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.sys.entity.ContractEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContractDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/sys/dao/StoreProfileDao.java b/src/main/java/io/as/modules/sys/dao/StoreProfileDao.java new file mode 100755 index 0000000..7aec94e --- /dev/null +++ b/src/main/java/io/as/modules/sys/dao/StoreProfileDao.java @@ -0,0 +1,12 @@ +package io.as.modules.sys.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.sys.entity.StoreProfileEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 门店扩展表 + */ +@Mapper +public interface StoreProfileDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/sys/dao/SysCaptchaDao.java b/src/main/java/io/as/modules/sys/dao/SysCaptchaDao.java new file mode 100644 index 0000000..80094a4 --- /dev/null +++ b/src/main/java/io/as/modules/sys/dao/SysCaptchaDao.java @@ -0,0 +1,9 @@ +package io.as.modules.sys.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.sys.entity.SysCaptchaEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysCaptchaDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/sys/dao/SysConfigDao.java b/src/main/java/io/as/modules/sys/dao/SysConfigDao.java new file mode 100755 index 0000000..7810942 --- /dev/null +++ b/src/main/java/io/as/modules/sys/dao/SysConfigDao.java @@ -0,0 +1,27 @@ +package io.as.modules.sys.dao; + + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.sys.entity.SysConfigEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 系统配置信息 + * + * @author Mark sunlightcs@gmail.com + */ +@Mapper +public interface SysConfigDao extends BaseMapper { + + /** + * 根据key,查询value + */ + SysConfigEntity queryByKey(String paramKey); + + /** + * 根据key,更新value + */ + int updateValueByKey(@Param("paramKey") String paramKey, @Param("paramValue") String paramValue); + +} diff --git a/src/main/java/io/as/modules/sys/dao/SysMenuDao.java b/src/main/java/io/as/modules/sys/dao/SysMenuDao.java new file mode 100644 index 0000000..17651a8 --- /dev/null +++ b/src/main/java/io/as/modules/sys/dao/SysMenuDao.java @@ -0,0 +1,13 @@ +package io.as.modules.sys.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.sys.entity.SysMenuEntity; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface SysMenuDao extends BaseMapper { + List queryListParentId(Long parentId); + List queryNotButtonList(); +} diff --git a/src/main/java/io/as/modules/sys/dao/SysOrgDao.java b/src/main/java/io/as/modules/sys/dao/SysOrgDao.java new file mode 100644 index 0000000..9172b4e --- /dev/null +++ b/src/main/java/io/as/modules/sys/dao/SysOrgDao.java @@ -0,0 +1,34 @@ +package io.as.modules.sys.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.sys.entity.SysOrgEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 组织管理 + */ +@Mapper +public interface SysOrgDao extends BaseMapper { + + /** + * 根据父ID查询子组织列表(未删除) + */ + List queryListByParentId(@Param("parentId") Long parentId); + + /** + * 统计该组织及子级拥有的卡数量(card.org_id 在组织子树内) + * @param orgId 组织ID + * @param mpath 组织 mpath,用于匹配子级;可为 null,则仅统计 org_id = orgId + */ + Long countCardInSubtree(@Param("orgId") Long orgId, @Param("mpath") String mpath); + + /** + * 统计已分配到该组织及子级下门店的卡数量(card.org_id 在子树内且 org 类型为 STORE) + * @param orgId 组织ID + * @param mpath 组织 mpath;可为 null,则仅统计 org_id = orgId 且 type=STORE + */ + Long countStoreAllocatedCardInSubtree(@Param("orgId") Long orgId, @Param("mpath") String mpath); +} diff --git a/src/main/java/io/as/modules/sys/dao/SysRoleDao.java b/src/main/java/io/as/modules/sys/dao/SysRoleDao.java new file mode 100644 index 0000000..9b7e129 --- /dev/null +++ b/src/main/java/io/as/modules/sys/dao/SysRoleDao.java @@ -0,0 +1,12 @@ +package io.as.modules.sys.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.sys.entity.SysRoleEntity; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface SysRoleDao extends BaseMapper { + List queryRoleIdList(); +} diff --git a/src/main/java/io/as/modules/sys/dao/SysRoleMenuDao.java b/src/main/java/io/as/modules/sys/dao/SysRoleMenuDao.java new file mode 100644 index 0000000..6f9f2c4 --- /dev/null +++ b/src/main/java/io/as/modules/sys/dao/SysRoleMenuDao.java @@ -0,0 +1,13 @@ +package io.as.modules.sys.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.sys.entity.SysRoleMenuEntity; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface SysRoleMenuDao extends BaseMapper { + List queryMenuIdList(Long roleId); + int deleteBatch(Long[] roleIds); +} diff --git a/src/main/java/io/as/modules/sys/dao/SysUserDao.java b/src/main/java/io/as/modules/sys/dao/SysUserDao.java new file mode 100644 index 0000000..e2948a6 --- /dev/null +++ b/src/main/java/io/as/modules/sys/dao/SysUserDao.java @@ -0,0 +1,14 @@ +package io.as.modules.sys.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.sys.entity.SysUserEntity; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface SysUserDao extends BaseMapper { + List queryAllPerms(Long userId); + List queryAllMenuId(Long userId); + SysUserEntity queryByUserName(String username); +} diff --git a/src/main/java/io/as/modules/sys/dao/SysUserRoleDao.java b/src/main/java/io/as/modules/sys/dao/SysUserRoleDao.java new file mode 100644 index 0000000..16f54a7 --- /dev/null +++ b/src/main/java/io/as/modules/sys/dao/SysUserRoleDao.java @@ -0,0 +1,13 @@ +package io.as.modules.sys.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.sys.entity.SysUserRoleEntity; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface SysUserRoleDao extends BaseMapper { + List queryRoleIdList(Long userId); + int deleteBatch(Long[] roleIds); +} diff --git a/src/main/java/io/as/modules/sys/dao/SysUserTokenDao.java b/src/main/java/io/as/modules/sys/dao/SysUserTokenDao.java new file mode 100644 index 0000000..fd1fd81 --- /dev/null +++ b/src/main/java/io/as/modules/sys/dao/SysUserTokenDao.java @@ -0,0 +1,19 @@ +package io.as.modules.sys.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.sys.entity.SysUserTokenEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.Date; + +@Mapper +public interface SysUserTokenDao extends BaseMapper { + SysUserTokenEntity queryByToken(String token); + SysUserTokenEntity queryByRefreshToken(String refreshToken); + SysUserTokenEntity queryByPreviousRefreshToken(String previousRefreshToken); + int updateTokenRotation(@Param("userId") Long userId, @Param("currentRefreshToken") String currentRefreshToken, + @Param("newToken") String newToken, @Param("newExpireTime") Date newExpireTime, + @Param("newRefreshToken") String newRefreshToken, @Param("newRefreshExpireTime") Date newRefreshExpireTime, + @Param("updateTime") Date updateTime); +} diff --git a/src/main/java/io/as/modules/sys/entity/AuditLogEntity.java b/src/main/java/io/as/modules/sys/entity/AuditLogEntity.java new file mode 100755 index 0000000..1b37709 --- /dev/null +++ b/src/main/java/io/as/modules/sys/entity/AuditLogEntity.java @@ -0,0 +1,49 @@ +package io.as.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 审计日志 + */ +@Data +@TableName("audit_log") +public class AuditLogEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + private Long actorUserId; + /** + * 操作人显示名,对应 sys_user.username(表外字段,查询列表时填充) + */ + @TableField(exist = false) + private String name; + private String head; + private String method; + private String api; + private String param; + private String result; + @TableField("`before`") + private String before; + @TableField("`after`") + private String after; + private String reason; + private String ip; + private String deviceInfo; + private String ticketNo; + private Date createdAt; + private Date updatedAt; + + /** + * 请求参数中文描述(非数据库字段,用于展示) + */ + @TableField(exist = false) + private String paramDesc; +} diff --git a/src/main/java/io/as/modules/sys/entity/CardEntity.java b/src/main/java/io/as/modules/sys/entity/CardEntity.java new file mode 100755 index 0000000..a67a8bd --- /dev/null +++ b/src/main/java/io/as/modules/sys/entity/CardEntity.java @@ -0,0 +1,74 @@ +package io.as.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 卡表(核心资产) + */ +@Data +@JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) +@TableName("card") +public class CardEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 卡号(对外展示),本系统内部 */ + private String cardNo; + /** 导入的激活码 */ + private String cardCode; + /** 第三方引用(导入的卡号) */ + private String thirdPartyRef; + /** 传到第三方的唯一ID,数字+字母随机串,导入时生成后固定 */ + private String thirdCardNo; + /** 卡类型:YEAR(年)/QUARTER(季)/MONTH(月)/WEEK(周)/OTHER(其他) */ + private String cardType; + /** 供应商ID */ + private Long supplierId; + /** 库存状态:IN_STOCK(库存中)/SOLD(归属合同) */ + private String inventoryStatus; + /** 分配场景:NONE(合同普通库存)/ACTIVE_CODE(激活码预留池)/PRE_IMPORT(预置导入绑定) */ + private String allocationScene; + /** 使用状态:UNUSED(未使用-未绑定)/BOUND(已绑定)/ACTIVATING(激活校验中)/ACTIVATION_FAILED(激活失败可重试)/ACTIVE(已生效)/WAITING_MIGRATION(待迁移)/EXPIRED(已过期) */ + private String usageStatus; + /** 激活码预留池ID(仅 SOLD+ACTIVE_CODE+UNUSED 时非空;领用绑定进入 BOUND 后置空) */ + private Long contractActivationCodeId; + /** 卡当前所属组织ID sys_org.id(用于强隔离/索引/可分区) */ + private Long orgId; + /** 分配单ID(非分配可为空) */ + private Long allocationOrderId; + /** 导入批次ID(关联 import_batch 表) */ + private Long importBatchId; + /** 第三方导入批次(冗余) */ + private String thirdImportBatch; + /** 售出价格(冗余) */ + private BigDecimal price; + /** 删除标记:UNDEL未删除 / DEL已删除 */ + private String deleteFlag; + /** 卡的过期时间,本系统过期时间 */ + private Date expireAt; + /** 第三方系统有效期开始时间 */ + private Date thirdStartAt; + /** 第三方系统有效期过期时间 */ + private Date thirdExpireAt; + /** 激活时间 */ + private Date activeAt; + private Date createdAt; + private Date updatedAt; + + /** 卡所属组织名称(非表字段,接口返回时填充) */ + @TableField(exist = false) + private String orgName; + /** 卡设备绑定表最后一条记录(id 最大)的创建时间,与列表激活时间筛选口径一致(非表字段) */ + @TableField(exist = false) + private Date cardDeviceBindingCreatedAt; +} diff --git a/src/main/java/io/as/modules/sys/entity/CardTransferEntity.java b/src/main/java/io/as/modules/sys/entity/CardTransferEntity.java new file mode 100755 index 0000000..4995b02 --- /dev/null +++ b/src/main/java/io/as/modules/sys/entity/CardTransferEntity.java @@ -0,0 +1,28 @@ +package io.as.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 卡流转历史 + */ +@Data +@TableName("card_transfer") +public class CardTransferEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + private Long cardId; + private Long fromOrgId; + private Long toOrgId; + private String actionType; + private String deleteFlag; + private Date createdAt; + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/sys/entity/ContractActivationCodeEntity.java b/src/main/java/io/as/modules/sys/entity/ContractActivationCodeEntity.java new file mode 100755 index 0000000..00750a8 --- /dev/null +++ b/src/main/java/io/as/modules/sys/entity/ContractActivationCodeEntity.java @@ -0,0 +1,29 @@ +package io.as.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 权益激活码(合同维度) + */ +@Data +@TableName("contract_activation_code") +public class ContractActivationCodeEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + private Long contractId; + private String code; + private String cardType; + private String status; + private String deleteFlag; + private Long storeOrgId; + private Date createdAt; + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/sys/entity/ContractEntity.java b/src/main/java/io/as/modules/sys/entity/ContractEntity.java new file mode 100755 index 0000000..bf8021b --- /dev/null +++ b/src/main/java/io/as/modules/sys/entity/ContractEntity.java @@ -0,0 +1,27 @@ +package io.as.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 合同表(与门店组织 1:1 绑定) + */ +@Data +@TableName("contract") +public class ContractEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + private Long storeOrgId; + private String contractNo; + private String status; + private String deleteFlag; + private Date createdAt; + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/sys/entity/StoreProfileEntity.java b/src/main/java/io/as/modules/sys/entity/StoreProfileEntity.java new file mode 100755 index 0000000..b4b2785 --- /dev/null +++ b/src/main/java/io/as/modules/sys/entity/StoreProfileEntity.java @@ -0,0 +1,61 @@ +package io.as.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 门店扩展表:与 sys_org(type=STORE) 一对一 + */ +@Data +@TableName("store_profile") +public class StoreProfileEntity implements Serializable { + private static final long serialVersionUID = 1L; + + /** 组织ID,关联 sys_org.id(type=STORE) */ + @TableId(type = IdType.INPUT) + private Long orgId; + + /** 门店名称 */ + private String storeName; + + /** 法人 */ + private String legalPerson; + + /** 营业执照号 */ + private String licenseNo; + + /** 营业执照图片URL */ + private String licenseImageUrl; + + /** 国家,默认中国,冗余 */ + private String country; + + /** 省/市/县,单字段冗余 */ + private String region; + + /** 详细地址 */ + private String address; + + /** 联系电话 */ + private String phone; + + /** 备注 */ + private String remark; + + /** 状态 ACTIVE(启用/营业)/CLOSED(停用/关闭) */ + private String status; + + /** 删除标记 UNDEL未删除/DEL删除 */ + private String deleteFlag; + + /** 创建时间 */ + private Date createdAt; + + /** 更新时间 */ + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/sys/entity/SysCaptchaEntity.java b/src/main/java/io/as/modules/sys/entity/SysCaptchaEntity.java new file mode 100644 index 0000000..d2285a6 --- /dev/null +++ b/src/main/java/io/as/modules/sys/entity/SysCaptchaEntity.java @@ -0,0 +1,17 @@ +package io.as.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +@Data +@TableName("sys_captcha") +public class SysCaptchaEntity { + @TableId(type = IdType.INPUT) + private String uuid; + private String code; + private Date expireTime; +} diff --git a/src/main/java/io/as/modules/sys/entity/SysConfigEntity.java b/src/main/java/io/as/modules/sys/entity/SysConfigEntity.java new file mode 100755 index 0000000..4f96550 --- /dev/null +++ b/src/main/java/io/as/modules/sys/entity/SysConfigEntity.java @@ -0,0 +1,25 @@ +package io.as.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +/** + * 系统配置信息 + * + * @author Mark sunlightcs@gmail.com + */ +@Data +@TableName("sys_config") +public class SysConfigEntity { + @TableId + private Long id; + @NotBlank(message="参数名不能为空") + private String paramKey; + @NotBlank(message="参数值不能为空") + private String paramValue; + private String remark; + +} diff --git a/src/main/java/io/as/modules/sys/entity/SysMenuEntity.java b/src/main/java/io/as/modules/sys/entity/SysMenuEntity.java new file mode 100644 index 0000000..43106fa --- /dev/null +++ b/src/main/java/io/as/modules/sys/entity/SysMenuEntity.java @@ -0,0 +1,42 @@ +package io.as.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +@Data +@TableName("sys_menu") +public class SysMenuEntity implements Serializable, Comparable { + private static final long serialVersionUID = 1L; + @TableId(type = IdType.AUTO) + private Long id; + private Long parentId; + @TableField(exist=false) + private String parentName; + private String name; + private String url; + private String perms; + private Integer type; + private String icon; + @TableField("`show`") + private String show; + private Integer orderNum; + private Date createdAt; + private Date updatedAt; + @TableField(exist=false) + private Boolean open; + @TableField(exist=false) + private List list = new ArrayList<>(); + + @Override + public int compareTo(SysMenuEntity o) { + return this.getOrderNum() - o.getOrderNum(); + } +} diff --git a/src/main/java/io/as/modules/sys/entity/SysOrgEntity.java b/src/main/java/io/as/modules/sys/entity/SysOrgEntity.java new file mode 100755 index 0000000..f45f001 --- /dev/null +++ b/src/main/java/io/as/modules/sys/entity/SysOrgEntity.java @@ -0,0 +1,254 @@ +package io.as.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.as.modules.sys.vo.StoreCardTypeStatsVo; + + + +import javax.validation.constraints.NotBlank; +import java.io.Serializable; +import java.util.Date; +import java.util.List; + +/** + * 组织 + */ +@Data +@TableName("sys_org") +public class SysOrgEntity implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 组织ID + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 父组织ID;type=PLATFORM时为NULL + */ + private Long parentId; + + /** + * 子树路径(materialized-path),例如:1.2.3. + */ + private String mpath; + + /** + * 组织名称 + */ + @NotBlank(message = "组织名称不能为空") + private String name; + + /** + * 同级排序 + */ + private Integer orderNo; + + /** + * 类型 PLATFORM(平台)/CHANNEL(渠道)/SERVICE_PROVIDER(代理服务商)/STORE(门店) + */ + private String type; + + /** + * 组织编码,全局唯一;type=CHANNEL时作为渠道标识码(6位随机)。接口入参/出参可用 orgCode。 + */ + @JsonProperty("orgCode") + private String code; + + /** + * 是否允许持仓(用于约束库存主体);渠道与代理商(SERVICE_PROVIDER)可配置,门店等为 false + */ + private Boolean canHoldInventory; + + /** + * 权益激活码前缀;type=CHANNEL时必填且全局唯一(2位) + */ + private String activationPrefix; + + /** + * 渠道品牌Logo;type=CHANNEL可配置;子树继承展示 + */ + private String brandLogoUrl; + + /** + * 拓展/开发人员(仅 type=SERVICE_PROVIDER 代理商;其他组织类型不落库、保存时清空) + */ + private String developmentStaff; + + /** + * 联系电话(仅 type=SERVICE_PROVIDER 代理商;其他类型保存时清空) + */ + private String contactPhone; + + /** + * 服务合伙人说明(仅 type=SERVICE_PROVIDER 代理商;其他类型保存时清空) + */ + private String servicePartner; + + /** + * 企业微信二维码图片 URL(仅 type=SERVICE_PROVIDER 代理商;其他类型保存时清空) + */ + private String wechatQrImageUrl; + + /** + * 状态 ACTIVE(启用)/DISABLED(禁用) + */ + private String status; + + /** + * 创建者用户ID + */ + private Long createBy; + + /** + * 更新者用户ID + */ + private Long updateBy; + + /** + * 删除标记 UNDEL未删除/DEL删除 + */ + private String deleteFlag; + + /** + * 创建时间 + */ + private Date createdAt; + + /** + * 更新时间 + */ + private Date updatedAt; + + /** + * 子组织列表(非表字段) + */ +@TableField(exist = false) + private List children; + + /** + * 渠道/代理商/门店登录电话(type=CHANNEL、SERVICE_PROVIDER 或 STORE 时前端可传,用于创建登录用户,非表字段) + */ +@TableField(exist = false) + private String loginMobile; + + /** + * 渠道/代理商/门店登录密码(type=CHANNEL、SERVICE_PROVIDER 或 STORE 时前端可传,用于创建登录用户,非表字段) + */ +@TableField(exist = false) + private String loginPassword; + + /** + * 父组织名称(非表字段,列表/详情时填充;门店即所属代理商名称) + */ +@TableField(exist = false) + private String parentOrgName; + + /** + * 父组织编码(非表字段,列表/详情时填充) + */ +@TableField(exist = false) + private String parentOrgCode; + + /** + * 门店扩展信息(非表字段;type=STORE 时列表/详情返回,保存/更新时传入) + */ +@TableField(exist = false) + private StoreProfileEntity storeProfile; + + /** + * 该组织及子级拥有的卡数量(非表字段,列表时填充) + */ +@TableField(exist = false) + private Long cardCountIncludingChildren; + + /** + * 已分配到门店的卡数量(含该组织及子级下的门店,非表字段,列表时填充) + */ +@TableField(exist = false) + private Long storeAllocatedCardCountIncludingChildren; + + /** + * 门店绑定卡数量(非表字段,仅门店搜索接口填充):delete_flag=UNDEL 且 org_id=门店 + */ +@TableField(exist = false) + private Long storeCards; + + /** + * 门店已使用卡数量(非表字段,仅门店搜索接口填充):usage_status 不为 UNUSED 的卡张数 + */ +@TableField(exist = false) + private Long cardUsedCount; + + /** + * 按卡类型统计的使用情况(非表字段,仅门店搜索接口填充) + */ +@TableField(exist = false) + private List cardStatsByType; + + /** + * 激活码组(非表字段,门店时填充):关联 contract_activation_code.code,多个用 / 拼接 + */ +@TableField(exist = false) + private String activationCodeGroup; + + /** + * 年卡激活码(非表字段,门店时填充):关联 contract_activation_code.code 且 card_type=YEAR + */ +@TableField(exist = false) + private String yearActivationCode; + + /** + * 季卡激活码(非表字段,门店时填充):关联 contract_activation_code.code 且 card_type=QUARTER + */ +@TableField(exist = false) + private String quarterActivationCode; + + /** + * 月卡激活码(非表字段,门店时填充):关联 contract_activation_code.code 且 card_type=MONTH + */ +@TableField(exist = false) + private String monthActivationCode; + + /** + * 周卡激活码(非表字段,门店时填充):关联 contract_activation_code.code 且 card_type=WEEK + */ +@TableField(exist = false) + private String weekActivationCode; + + /** + * 其他类型激活码(非表字段,门店时填充):关联 contract_activation_code.code 且 card_type=OTHER + */ +@TableField(exist = false) + private String otherActivationCode; + + /** + * 所属渠道的激活码前缀(非表字段,组织树时填充) + */ +@TableField(exist = false) + private String rootChannelActivationPrefix; + + /** + * 渠道名称(非表字段,门店搜索时根据mpath填充) + */ +@TableField(exist = false) + private String channelName; + + /** + * 一级代理商名称(非表字段,门店搜索时根据mpath填充) + */ +@TableField(exist = false) + private String primaryAgentName; + + /** + * 二级代理商名称(非表字段,门店搜索时根据mpath填充) + */ +@TableField(exist = false) + private String secondaryAgentName; +} diff --git a/src/main/java/io/as/modules/sys/entity/SysRoleEntity.java b/src/main/java/io/as/modules/sys/entity/SysRoleEntity.java new file mode 100644 index 0000000..2934671 --- /dev/null +++ b/src/main/java/io/as/modules/sys/entity/SysRoleEntity.java @@ -0,0 +1,28 @@ +package io.as.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import java.io.Serializable; +import java.util.Date; +import java.util.List; + +@Data +@TableName("sys_role") +public class SysRoleEntity implements Serializable { + private static final long serialVersionUID = 1L; + @TableId(type = IdType.AUTO) + private Long id; + @NotBlank(message="角色名称不能为空") + private String roleName; + private String remark; + private String status; + private Date createdAt; + private Date updatedAt; + @TableField(exist=false) + private List menuIdList; +} diff --git a/src/main/java/io/as/modules/sys/entity/SysRoleMenuEntity.java b/src/main/java/io/as/modules/sys/entity/SysRoleMenuEntity.java new file mode 100644 index 0000000..d3d5da9 --- /dev/null +++ b/src/main/java/io/as/modules/sys/entity/SysRoleMenuEntity.java @@ -0,0 +1,17 @@ +package io.as.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; + +@Data +@TableName("sys_role_menu") +public class SysRoleMenuEntity implements Serializable { + private static final long serialVersionUID = 1L; + @TableId + private Long id; + private Long roleId; + private Long menuId; +} diff --git a/src/main/java/io/as/modules/sys/entity/SysUserEntity.java b/src/main/java/io/as/modules/sys/entity/SysUserEntity.java new file mode 100755 index 0000000..5406a38 --- /dev/null +++ b/src/main/java/io/as/modules/sys/entity/SysUserEntity.java @@ -0,0 +1,104 @@ +package io.as.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonAlias; +import io.as.common.validator.group.AddGroup; +import io.as.common.validator.group.UpdateGroup; +import lombok.Data; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import java.io.Serializable; +import java.util.Date; +import java.util.List; + +/** + * 系统用户 + * + * @author Mark sunlightcs@gmail.com + */ +@Data +@TableName("sys_user") +public class SysUserEntity implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 用户ID(前端可传 userId 或 id) + */ + @TableId(type = IdType.AUTO) + @JsonAlias("userId") + private Long id; + + /** + * 用户名 + */ + @NotBlank(message="用户名不能为空", groups = {AddGroup.class, UpdateGroup.class}) + private String username; + + /** + * 密码 + */ + @NotBlank(message="密码不能为空", groups = AddGroup.class) + private String password; + + /** + * 盐 + */ + private String salt; + + /** + * 头像 + */ + private String avatar; + + /** + * 邮箱 + */ + private String email; + + /** + * 手机号 + */ + private String mobile; + + /** + * 备注 + */ + private String remark; + + /** + * 状态 0:禁用 1:正常 + */ + private Integer status; + + /** + * 所属组织ID + */ + private Long orgId; + + /** + * 创建时间 + */ + private Date createdAt; + + /** + * 更新时间 + */ + private Date updatedAt; + + /** + * 角色ID列表 + */ + @TableField(exist=false) + private List roleIdList; + + /** + * 所属组织名称(非表字段,列表返回时填充) + */ + @TableField(exist=false) + private String orgName; + +} diff --git a/src/main/java/io/as/modules/sys/entity/SysUserRoleEntity.java b/src/main/java/io/as/modules/sys/entity/SysUserRoleEntity.java new file mode 100644 index 0000000..6063686 --- /dev/null +++ b/src/main/java/io/as/modules/sys/entity/SysUserRoleEntity.java @@ -0,0 +1,17 @@ +package io.as.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; + +@Data +@TableName("sys_user_role") +public class SysUserRoleEntity implements Serializable { + private static final long serialVersionUID = 1L; + @TableId + private Long id; + private Long userId; + private Long roleId; +} diff --git a/src/main/java/io/as/modules/sys/entity/SysUserTokenEntity.java b/src/main/java/io/as/modules/sys/entity/SysUserTokenEntity.java new file mode 100644 index 0000000..7e615eb --- /dev/null +++ b/src/main/java/io/as/modules/sys/entity/SysUserTokenEntity.java @@ -0,0 +1,23 @@ +package io.as.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +@Data +@TableName("sys_user_token") +public class SysUserTokenEntity implements Serializable { + private static final long serialVersionUID = 1L; + @TableId(type = IdType.INPUT) + private Long userId; + private String token; + private Date expireTime; + private String refreshToken; + private Date refreshExpireTime; + private String previousRefreshToken; + private Date updateTime; +} diff --git a/src/main/java/io/as/modules/sys/form/ByStoreRequest.java b/src/main/java/io/as/modules/sys/form/ByStoreRequest.java new file mode 100644 index 0000000..db54901 --- /dev/null +++ b/src/main/java/io/as/modules/sys/form/ByStoreRequest.java @@ -0,0 +1,15 @@ +package io.as.modules.sys.form; + +import lombok.Data; + +import javax.validation.constraints.NotNull; + +/** + * 按门店查询合同/权益激活码列表请求 + */ +@Data +public class ByStoreRequest { + + @NotNull(message = "门店ID(orgId)不能为空") + private Long orgId; +} diff --git a/src/main/java/io/as/modules/sys/form/ContractActivationCodeRequest.java b/src/main/java/io/as/modules/sys/form/ContractActivationCodeRequest.java new file mode 100644 index 0000000..81c4529 --- /dev/null +++ b/src/main/java/io/as/modules/sys/form/ContractActivationCodeRequest.java @@ -0,0 +1,20 @@ +package io.as.modules.sys.form; + +import lombok.Data; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Min; + +/** + * 设置合同权益激活码与设备上限请求 + */ +@Data +public class ContractActivationCodeRequest { + + private String code; + private String cardType; + + @NotNull(message = "剩余可激活设备数量不能为空") + @Min(value = 0, message = "剩余可激活设备数量不能为负") + private Integer maxDeviceCount; +} diff --git a/src/main/java/io/as/modules/sys/form/PasswordForm.java b/src/main/java/io/as/modules/sys/form/PasswordForm.java new file mode 100644 index 0000000..37344fe --- /dev/null +++ b/src/main/java/io/as/modules/sys/form/PasswordForm.java @@ -0,0 +1,9 @@ +package io.as.modules.sys.form; + +import lombok.Data; + +@Data +public class PasswordForm { + private String password; + private String newPassword; +} diff --git a/src/main/java/io/as/modules/sys/form/SysLoginForm.java b/src/main/java/io/as/modules/sys/form/SysLoginForm.java new file mode 100644 index 0000000..cc6f6de --- /dev/null +++ b/src/main/java/io/as/modules/sys/form/SysLoginForm.java @@ -0,0 +1,11 @@ +package io.as.modules.sys.form; + +import lombok.Data; + +@Data +public class SysLoginForm { + private String username; + private String password; + private String captcha; + private String uuid; +} diff --git a/src/main/java/io/as/modules/sys/form/UpdateMaxDeviceCountRequest.java b/src/main/java/io/as/modules/sys/form/UpdateMaxDeviceCountRequest.java new file mode 100644 index 0000000..e420dd3 --- /dev/null +++ b/src/main/java/io/as/modules/sys/form/UpdateMaxDeviceCountRequest.java @@ -0,0 +1,20 @@ +package io.as.modules.sys.form; + +import lombok.Data; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Min; + +/** + * 仅修改权益激活码设备上限请求 + */ +@Data +public class UpdateMaxDeviceCountRequest { + + @NotNull(message = "剩余可激活设备数量不能为空") + @Min(value = 0, message = "剩余可激活设备数量不能为负") + private Integer maxDeviceCount; + + private String cardType; + private String code; +} diff --git a/src/main/java/io/as/modules/sys/jwt/JWTFilter.java b/src/main/java/io/as/modules/sys/jwt/JWTFilter.java new file mode 100644 index 0000000..79ae6bb --- /dev/null +++ b/src/main/java/io/as/modules/sys/jwt/JWTFilter.java @@ -0,0 +1,76 @@ +package io.as.modules.sys.jwt; + +import com.google.gson.Gson; +import io.as.common.utils.HttpContextUtils; +import io.as.common.utils.R; +import org.apache.commons.lang.StringUtils; +import org.apache.http.HttpStatus; +import org.apache.shiro.authc.AuthenticationException; +import org.apache.shiro.authc.AuthenticationToken; +import org.apache.shiro.web.filter.authc.AuthenticatingFilter; +import org.springframework.web.bind.annotation.RequestMethod; + +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +public class JWTFilter extends AuthenticatingFilter { + + @Override + protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception { + String token = getRequestToken((HttpServletRequest) request); + if (StringUtils.isBlank(token)) { + return null; + } + return new JWTToken(token); + } + + @Override + protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { + HttpServletRequest httpRequest = (HttpServletRequest) request; + if (RequestMethod.OPTIONS.name().equals(httpRequest.getMethod())) { + return true; + } + return false; + } + + @Override + protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { + String token = getRequestToken((HttpServletRequest) request); + if (StringUtils.isBlank(token)) { + HttpServletResponse httpResponse = (HttpServletResponse) response; + httpResponse.setHeader("Access-Control-Allow-Credentials", "false"); + httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin()); + String json = new Gson().toJson(R.error(HttpStatus.SC_UNAUTHORIZED, "invalid token")); + httpResponse.getWriter().print(json); + return false; + } + return executeLogin(request, response); + } + + @Override + protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) { + HttpServletResponse httpResponse = (HttpServletResponse) response; + httpResponse.setContentType("application/json;charset=utf-8"); + httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin()); + try { + Throwable throwable = e.getCause() == null ? e : e.getCause(); + R r = R.error(HttpStatus.SC_UNAUTHORIZED, throwable.getMessage()); + String json = new Gson().toJson(r); + httpResponse.getWriter().print(json); + } catch (IOException e1) { + } + return false; + } + + private String getRequestToken(HttpServletRequest httpRequest) { + String authHeader = httpRequest.getHeader("Authorization"); + if (StringUtils.isNotBlank(authHeader) && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7).trim(); + if (StringUtils.isNotBlank(token)) return token; + } + return null; + } +} diff --git a/src/main/java/io/as/modules/sys/jwt/JWTRealm.java b/src/main/java/io/as/modules/sys/jwt/JWTRealm.java new file mode 100644 index 0000000..3621461 --- /dev/null +++ b/src/main/java/io/as/modules/sys/jwt/JWTRealm.java @@ -0,0 +1,50 @@ +package io.as.modules.sys.jwt; + +import io.as.modules.sys.entity.SysUserEntity; +import io.as.modules.sys.entity.SysUserTokenEntity; +import io.as.modules.sys.service.ShiroService; +import org.apache.shiro.authc.*; +import org.apache.shiro.authz.AuthorizationInfo; +import org.apache.shiro.authz.SimpleAuthorizationInfo; +import org.apache.shiro.realm.AuthorizingRealm; +import org.apache.shiro.subject.PrincipalCollection; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Set; + +@Component +public class JWTRealm extends AuthorizingRealm { + @Autowired + private ShiroService shiroService; + + @Override + public boolean supports(AuthenticationToken token) { + return token instanceof JWTToken; + } + + @Override + protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { + SysUserEntity user = (SysUserEntity) principals.getPrimaryPrincipal(); + Long userId = user.getId(); + Set permsSet = shiroService.getUserPermissions(userId); + SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); + info.setStringPermissions(permsSet); + return info; + } + + @Override + protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { + String accessToken = (String) token.getPrincipal(); + SysUserTokenEntity tokenEntity = shiroService.queryByToken(accessToken); + if (tokenEntity == null || tokenEntity.getExpireTime().getTime() < System.currentTimeMillis()) { + throw new IncorrectCredentialsException("token失效,请重新登录"); + } + SysUserEntity user = shiroService.queryUser(tokenEntity.getUserId()); + if (user.getStatus() == 0) { + throw new LockedAccountException("账号已被锁定,请联系管理员"); + } + SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, accessToken, getName()); + return info; + } +} diff --git a/src/main/java/io/as/modules/sys/jwt/JWTToken.java b/src/main/java/io/as/modules/sys/jwt/JWTToken.java new file mode 100644 index 0000000..06553c9 --- /dev/null +++ b/src/main/java/io/as/modules/sys/jwt/JWTToken.java @@ -0,0 +1,21 @@ +package io.as.modules.sys.jwt; + +import org.apache.shiro.authc.AuthenticationToken; + +public class JWTToken implements AuthenticationToken { + private String token; + + public JWTToken(String token) { + this.token = token; + } + + @Override + public String getPrincipal() { + return token; + } + + @Override + public Object getCredentials() { + return token; + } +} diff --git a/src/main/java/io/as/modules/sys/jwt/TokenGenerator.java b/src/main/java/io/as/modules/sys/jwt/TokenGenerator.java new file mode 100755 index 0000000..b5b3772 --- /dev/null +++ b/src/main/java/io/as/modules/sys/jwt/TokenGenerator.java @@ -0,0 +1,44 @@ +package io.as.modules.sys.jwt; + +import io.as.common.exception.ASException; + +import java.security.MessageDigest; +import java.util.UUID; + +/** + * 生成token + * + * @author Mark sunlightcs@gmail.com + */ +public class TokenGenerator { + + public static String generateValue() { + return generateValue(UUID.randomUUID().toString()); + } + + private static final char[] hexCode = "0123456789abcdef".toCharArray(); + + public static String toHexString(byte[] data) { + if(data == null) { + return null; + } + StringBuilder r = new StringBuilder(data.length*2); + for ( byte b : data) { + r.append(hexCode[(b >> 4) & 0xF]); + r.append(hexCode[(b & 0xF)]); + } + return r.toString(); + } + + public static String generateValue(String param) { + try { + MessageDigest algorithm = MessageDigest.getInstance("MD5"); + algorithm.reset(); + algorithm.update(param.getBytes()); + byte[] messageDigest = algorithm.digest(); + return toHexString(messageDigest); + } catch (Exception e) { + throw new ASException("生成Token失败", e); + } + } +} diff --git a/src/main/java/io/as/modules/sys/redis/SysConfigRedis.java b/src/main/java/io/as/modules/sys/redis/SysConfigRedis.java new file mode 100755 index 0000000..75b2b4a --- /dev/null +++ b/src/main/java/io/as/modules/sys/redis/SysConfigRedis.java @@ -0,0 +1,37 @@ +package io.as.modules.sys.redis; + + +import io.as.common.utils.RedisKeys; +import io.as.common.utils.RedisUtils; +import io.as.modules.sys.entity.SysConfigEntity; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 系统配置Redis + * + * @author Mark sunlightcs@gmail.com + */ +@Component +public class SysConfigRedis { + @Autowired + private RedisUtils redisUtils; + + public void saveOrUpdate(SysConfigEntity config) { + if(config == null){ + return ; + } + String key = RedisKeys.getSysConfigKey(config.getParamKey()); + redisUtils.set(key, config); + } + + public void delete(String configKey) { + String key = RedisKeys.getSysConfigKey(configKey); + redisUtils.delete(key); + } + + public SysConfigEntity get(String configKey){ + String key = RedisKeys.getSysConfigKey(configKey); + return redisUtils.get(key, SysConfigEntity.class); + } +} diff --git a/src/main/java/io/as/modules/sys/service/AuditLogService.java b/src/main/java/io/as/modules/sys/service/AuditLogService.java new file mode 100755 index 0000000..877006a --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/AuditLogService.java @@ -0,0 +1,17 @@ +package io.as.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.sys.entity.AuditLogEntity; + +import java.util.Map; + +/** + * 审计日志 + */ +public interface AuditLogService extends IService { + /** + * 分页查询(与 {@code /sys/auditLog/list} 入参一致) + */ + PageUtils queryPage(Map params); +} diff --git a/src/main/java/io/as/modules/sys/service/CardTransferService.java b/src/main/java/io/as/modules/sys/service/CardTransferService.java new file mode 100755 index 0000000..b10d8a0 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/CardTransferService.java @@ -0,0 +1,26 @@ +package io.as.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.modules.sys.entity.CardTransferEntity; + +import java.util.List; + +/** + * 卡流转历史(仅记录门店→门店 或 门店→上级代理商的流转,即合同→合同/门店的转移) + */ +public interface CardTransferService extends IService { + + /** + * 记录单条卡的流转(仅用于门店间/合同间转移,即 transfer 动作) + */ + void recordTransfer(Long cardId, Long fromOrgId, Long toOrgId, String actionType); + + /** + * 批量记录卡的流转(用于组织变更时卡的批量流转记录) + * @param cardIds 卡ID列表 + * @param fromOrgId 来源组织ID + * @param toOrgId 目标组织ID + * @param actionType 流转动作类型 + */ + void batchRecordTransfer(List cardIds, Long fromOrgId, Long toOrgId, String actionType); +} diff --git a/src/main/java/io/as/modules/sys/service/ContractService.java b/src/main/java/io/as/modules/sys/service/ContractService.java new file mode 100755 index 0000000..b81f077 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/ContractService.java @@ -0,0 +1,21 @@ +package io.as.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.modules.sys.entity.ContractEntity; + +import java.util.List; + +/** + * 合同表 + */ +public interface ContractService extends IService { + + /** 为门店组织创建唯一合同(PRD REQ-4.2.1:门店创建时自动生成) */ + ContractEntity createForStore(Long storeOrgId); + + /** 根据门店组织ID查询合同(门店与合同 1:1) */ + ContractEntity getByStoreOrgId(Long storeOrgId); + + /** 根据门店组织ID查询该门店下合同列表 */ + List listByStoreOrgId(Long storeOrgId); +} diff --git a/src/main/java/io/as/modules/sys/service/ShiroService.java b/src/main/java/io/as/modules/sys/service/ShiroService.java new file mode 100644 index 0000000..f2767ff --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/ShiroService.java @@ -0,0 +1,12 @@ +package io.as.modules.sys.service; + +import io.as.modules.sys.entity.SysUserEntity; +import io.as.modules.sys.entity.SysUserTokenEntity; + +import java.util.Set; + +public interface ShiroService { + Set getUserPermissions(long userId); + SysUserTokenEntity queryByToken(String token); + SysUserEntity queryUser(Long userId); +} diff --git a/src/main/java/io/as/modules/sys/service/StoreProfileService.java b/src/main/java/io/as/modules/sys/service/StoreProfileService.java new file mode 100755 index 0000000..c0c552a --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/StoreProfileService.java @@ -0,0 +1,10 @@ +package io.as.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.modules.sys.entity.StoreProfileEntity; + +/** + * 门店扩展表 + */ +public interface StoreProfileService extends IService { +} diff --git a/src/main/java/io/as/modules/sys/service/SysCaptchaService.java b/src/main/java/io/as/modules/sys/service/SysCaptchaService.java new file mode 100644 index 0000000..7ca8b30 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/SysCaptchaService.java @@ -0,0 +1,11 @@ +package io.as.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.modules.sys.entity.SysCaptchaEntity; + +import java.awt.image.BufferedImage; + +public interface SysCaptchaService extends IService { + BufferedImage getCaptcha(String uuid); + boolean validate(String uuid, String code); +} diff --git a/src/main/java/io/as/modules/sys/service/SysConfigService.java b/src/main/java/io/as/modules/sys/service/SysConfigService.java new file mode 100755 index 0000000..8f8a4e0 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/SysConfigService.java @@ -0,0 +1,52 @@ +package io.as.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.sys.entity.SysConfigEntity; + +import java.util.Map; + +/** + * 系统配置信息 + * + * @author Mark sunlightcs@gmail.com + */ +public interface SysConfigService extends IService { + + PageUtils queryPage(Map params); + + /** + * 保存配置信息 + */ + public void saveConfig(SysConfigEntity config); + + /** + * 更新配置信息 + */ + public void update(SysConfigEntity config); + + /** + * 根据key,更新value + */ + public void updateValueByKey(String key, String value); + + /** + * 删除配置信息 + */ + public void deleteBatch(Long[] ids); + + /** + * 根据key,获取配置的value值 + * + * @param key key + */ + public String getValue(String key); + + /** + * 根据key,获取value的Object对象 + * @param key key + * @param clazz Object对象 + */ + public T getConfigObject(String key, Class clazz); + +} diff --git a/src/main/java/io/as/modules/sys/service/SysConfigServiceImpl.java b/src/main/java/io/as/modules/sys/service/SysConfigServiceImpl.java new file mode 100755 index 0000000..8b13789 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/SysConfigServiceImpl.java @@ -0,0 +1 @@ + diff --git a/src/main/java/io/as/modules/sys/service/SysMenuService.java b/src/main/java/io/as/modules/sys/service/SysMenuService.java new file mode 100644 index 0000000..f7e0821 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/SysMenuService.java @@ -0,0 +1,14 @@ +package io.as.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.modules.sys.entity.SysMenuEntity; + +import java.util.List; + +public interface SysMenuService extends IService { + List queryListParentId(Long parentId, List menuIdList); + List queryListParentId(Long parentId); + List queryNotButtonList(); + List getUserMenuList(Long userId); + void delete(Long menuId); +} diff --git a/src/main/java/io/as/modules/sys/service/SysOrgService.java b/src/main/java/io/as/modules/sys/service/SysOrgService.java new file mode 100644 index 0000000..1d151cb --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/SysOrgService.java @@ -0,0 +1,165 @@ +package io.as.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.sys.entity.SysOrgEntity; +import io.as.modules.sys.vo.CardTypeDetailStatsVo; +import io.as.modules.sys.vo.OrgCardTypeAllocatableVo; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * 组织 + */ +public interface SysOrgService extends IService { + + PageUtils queryPage(Map params, Long operatorId); + + /** + * 保存组织 + */ + void saveOrg(SysOrgEntity org, Long operatorId); + + /** + * 更新组织 + */ + void updateOrg(SysOrgEntity org, Long operatorId); + + /** + * 删除组织(软删除) + */ + void deleteBatch(Long[] ids, Long operatorId); + + /** + * 校验操作人是否有权访问指定组织(本人所属组织或其子级) + */ + boolean isOrgInScope(Long orgId, Long operatorId); + + /** + * 指定组织及其子树范围内、未删除的全部门店组织 ID(type=STORE) + */ + List listDescendantStoreOrgIds(Long orgId); + + /** + * 根据父ID查询子组织列表(未删除) + */ + List queryListByParentId(Long parentId); + + /** + * 查询组织树(未删除、启用状态),按操作人数据隔离 + */ + List queryOrgTree(Long operatorId); + + /** + * 查询组织树(渠道下树形结构):不含门店;超管或 orgId 为空时顶层为平台、其下渠道与代理商,否则按数据范围;默认仅启用状态(ACTIVE) + */ + List queryOrgTreeExcludeStore(Long operatorId); + + /** + * 同上;status 为空或空白时不按 status 过滤;传入则仅保留该状态(如 ACTIVE)。 + * 若操作人绑定在代理商组织(SERVICE_PROVIDER)上:仅返回代理商节点组成的子树(自身及全部下级代理商),且忽略 status 参数。 + */ + List queryOrgTreeExcludeStore(Long operatorId, String status); + + /** + * 组织列表(扁平):按操作人数据范围返回;平台账号(超级管理员或 orgId 为空)返回全部,否则仅返回该账号所属组织及其子级;未删除、启用状态 + */ + List listOrgByScope(Long operatorId); + + /** + * 门店搜索(分页):仅门店类型,返回门店信息+门店绑定的卡列表;按操作人数据隔离 + */ + PageUtils queryStoreSearchPage(Map params, Long operatorId); + + /** + * 门店列表(不分页):当前操作人数据范围内的全部门店;可选 name、status、orgId、region、activationCode(按 contract_activation_code.code 模糊匹配),语义同 {@link #queryStoreSearchPage} + */ + List listStoresInScope(Map params, Long operatorId); + + /** + * 沿父级向上查找所属渠道组织ID;若自身为渠道则返回自身ID + */ + Long getChannelOrgIdForOrg(Long orgId); + + /** + * 自指定组织的 parent 起沿祖先向上,取最近的可持仓组织 ID(不套用登录账号数据范围): + * 渠道恒视为可持仓;代理商须 can_hold_inventory=true;遇平台类型则返回该平台 ID(平台库存语义由调用方处理 org_id 为空)。 + * 整条链上未命中则返回 null。 + */ + Long resolveNearestHoldInventoryAncestorOrgId(Long orgId); + + /** + * 预置绑定时解析应从哪个组织库存扣减:自门店直属上级起沿祖先向上,取最近的可持仓组织(渠道恒视为可持仓;代理商须 can_hold_inventory=true)。 + * 非超管须在「本人组织 mpath 前缀」可见范围内;且当登录组织为渠道或代理商时,不得选用比登录组织更深的下级组织(仅可选用本组织及其上级),与预置扣减、导入校验一致。 + * 找不到时返回 null。 + */ + Long resolvePresetInventorySourceOrgId(Long storeOrgId, Long operatorId); + + /** + * 权益激活码调增时解析实际扣减库存的组织:自门店父级起沿祖先向上,在登录账号所属组织子树内(超级管理员 orgId 为空时不截断)。 + * 仅当当前祖先不可持仓时才继续向上;一旦遇到可持仓(can_hold_inventory 或渠道),只在该组织校验在库可分配卡是否不少于 requiredCount, + * 满足则返回该组织 ID,不足则抛异常,不再向更上级查找。 + * + * @param storeOrgId 门店组织 ID + * @param operatorId 操作人用户 ID(用于限定向上搜索不超过其所属组织) + * @param cardType 卡类型,空则按 YEAR + * @param requiredCount 需要的张数(须大于 0) + */ + Long resolveActivationInventorySourceOrgId(Long storeOrgId, Long operatorId, String cardType, int requiredCount); + + /** + * 从组织 mpath 解析渠道组织 ID(第二段为渠道 ID),格式:平台ID.渠道ID.一级代理商... + */ + Long getChannelOrgIdFromMpath(String mpath); + + /** + * 批量:门店组织 ID → 所属渠道权益激活码前缀(activationPrefix);无渠道或无前缀时该门店不出现在 map 中 + */ + Map mapStoreOrgIdToChannelActivationPrefix(Collection storeOrgIds); + + /** + * 根据 orgId 返回该组织及子级下的卡统计:可分配数量、已激活数量、待激活数量;无权限时返回 null + */ + io.as.modules.sys.vo.OrgCardStatsVo getCardStatsByOrgId(Long orgId, Long operatorId); + + /** + * 按卡类型统计:可分配仅 card.orgId=orgId;已激活与待激活为 orgId 及其子级组织下卡。无权限时返回 null + */ + io.as.modules.sys.vo.OrgCardStatsByTypeVo getCardStatsByOrgIdWithType(Long orgId, Long operatorId); + + /** + * 返回所有非删除数据的卡统计(按卡类型分类):可分配数量、已激活数量、待激活数量 + */ + io.as.modules.sys.vo.OrgCardStatsByTypeVo getAllCardStatsWithType(); + + /** + * 按卡类型(年/月/天)明细统计:采购量、预置、激活码、异常、可用库存等。 + * orgId 为空或 1 为全平台;否则为该组织及其下级组织范围内统计。无权限时返回 null。 + */ + List getCardDetailStatsYearMonthDay(Long orgId, Long operatorId); + + /** + * 按卡类型返回可分配卡数量(IN_STOCK + UNUSED,third_expire_at 未过期)。 + * orgId 不传时:超管或平台组织账号按全平台(所有渠道);否则为当前登录账号所属组织及子级。 + * orgId 为 1 时与组织卡统计一致:全平台所有渠道。无权限时返回 null。 + * storeOrgId 非空时:按该门店所属渠道统计在库可分配卡(card.org_id = 渠道组织 ID);门店须在当前 orgId 解析出的数据范围内,且操作人有权访问该门店。 + */ + List getCardAllocatableOverviewByType(Long orgId, Long storeOrgId, Long operatorId); + + /** + * 根据组织ID,返回其所属渠道下的组织树(不含门店;根一般为渠道;受操作人数据范围限制) + */ + java.util.List listOrgsInSameChannel(Long orgId, Long operatorId); + + /** + * 当前操作人数据范围内全部渠道(扁平列表,type=CHANNEL、ACTIVE) + */ + List listChannelsInScope(Long operatorId); + + /** + * 指定渠道下全部代理商(SERVICE_PROVIDER)树形结构,不含门店;根为一级代理商(父组织为渠道) + */ + List queryAgentTreeUnderChannel(Long channelOrgId, Long operatorId); +} diff --git a/src/main/java/io/as/modules/sys/service/SysRoleMenuService.java b/src/main/java/io/as/modules/sys/service/SysRoleMenuService.java new file mode 100644 index 0000000..1db9bc6 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/SysRoleMenuService.java @@ -0,0 +1,12 @@ +package io.as.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.modules.sys.entity.SysRoleMenuEntity; + +import java.util.List; + +public interface SysRoleMenuService extends IService { + void saveOrUpdate(Long roleId, List menuIdList); + List queryMenuIdList(Long roleId); + int deleteBatch(Long[] roleIds); +} diff --git a/src/main/java/io/as/modules/sys/service/SysRoleService.java b/src/main/java/io/as/modules/sys/service/SysRoleService.java new file mode 100644 index 0000000..89f5632 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/SysRoleService.java @@ -0,0 +1,16 @@ +package io.as.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.sys.entity.SysRoleEntity; + +import java.util.List; +import java.util.Map; + +public interface SysRoleService extends IService { + PageUtils queryPage(Map params); + void saveRole(SysRoleEntity role, Long operatorId); + void update(SysRoleEntity role, Long operatorId); + void deleteBatch(Long[] roleIds); + List queryRoleIdList(); +} diff --git a/src/main/java/io/as/modules/sys/service/SysUserRoleService.java b/src/main/java/io/as/modules/sys/service/SysUserRoleService.java new file mode 100644 index 0000000..2335f9c --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/SysUserRoleService.java @@ -0,0 +1,12 @@ +package io.as.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.modules.sys.entity.SysUserRoleEntity; + +import java.util.List; + +public interface SysUserRoleService extends IService { + void saveOrUpdate(Long userId, List roleIdList); + List queryRoleIdList(Long userId); + int deleteBatch(Long[] roleIds); +} diff --git a/src/main/java/io/as/modules/sys/service/SysUserService.java b/src/main/java/io/as/modules/sys/service/SysUserService.java new file mode 100644 index 0000000..1761880 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/SysUserService.java @@ -0,0 +1,19 @@ +package io.as.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.sys.entity.SysUserEntity; + +import java.util.List; +import java.util.Map; + +public interface SysUserService extends IService { + PageUtils queryPage(Map params); + List queryAllPerms(Long userId); + List queryAllMenuId(Long userId); + SysUserEntity queryByUserName(String username); + void saveUser(SysUserEntity user, Long operatorId); + void update(SysUserEntity user, Long operatorId); + void deleteBatch(Long[] userIds); + boolean updatePassword(Long userId, String password, String newPassword); +} diff --git a/src/main/java/io/as/modules/sys/service/SysUserTokenService.java b/src/main/java/io/as/modules/sys/service/SysUserTokenService.java new file mode 100644 index 0000000..4800b46 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/SysUserTokenService.java @@ -0,0 +1,11 @@ +package io.as.modules.sys.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.R; +import io.as.modules.sys.entity.SysUserTokenEntity; + +public interface SysUserTokenService extends IService { + R createToken(long userId); + R refreshToken(String refreshToken); + void logout(long userId); +} diff --git a/src/main/java/io/as/modules/sys/service/impl/AuditLogServiceImpl.java b/src/main/java/io/as/modules/sys/service/impl/AuditLogServiceImpl.java new file mode 100755 index 0000000..be15a07 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/impl/AuditLogServiceImpl.java @@ -0,0 +1,20 @@ +package io.as.modules.sys.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.sys.dao.AuditLogDao; +import io.as.modules.sys.entity.AuditLogEntity; +import io.as.modules.sys.service.AuditLogService; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service("auditLogService") +public class AuditLogServiceImpl extends ServiceImpl implements AuditLogService { + + @Override + public PageUtils queryPage(Map params) { + return new PageUtils(this.page(new Query().getPage(params, "created_at", false))); + } +} diff --git a/src/main/java/io/as/modules/sys/service/impl/CardTransferServiceImpl.java b/src/main/java/io/as/modules/sys/service/impl/CardTransferServiceImpl.java new file mode 100644 index 0000000..83c9531 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/impl/CardTransferServiceImpl.java @@ -0,0 +1,17 @@ +package io.as.modules.sys.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.modules.sys.dao.CardTransferDao; +import io.as.modules.sys.entity.CardTransferEntity; +import io.as.modules.sys.service.CardTransferService; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service("cardTransferService") +public class CardTransferServiceImpl extends ServiceImpl implements CardTransferService { + @Override + public void recordTransfer(Long cardId, Long fromOrgId, Long toOrgId, String actionType) { } + @Override + public void batchRecordTransfer(List cardIds, Long fromOrgId, Long toOrgId, String actionType) { } +} diff --git a/src/main/java/io/as/modules/sys/service/impl/ContractServiceImpl.java b/src/main/java/io/as/modules/sys/service/impl/ContractServiceImpl.java new file mode 100644 index 0000000..c762c69 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/impl/ContractServiceImpl.java @@ -0,0 +1,20 @@ +package io.as.modules.sys.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.modules.sys.dao.ContractDao; +import io.as.modules.sys.entity.ContractEntity; +import io.as.modules.sys.service.ContractService; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service("contractService") +public class ContractServiceImpl extends ServiceImpl implements ContractService { + @Override + public ContractEntity createForStore(Long storeOrgId) { return null; } + @Override + public ContractEntity getByStoreOrgId(Long storeOrgId) { return null; } + @Override + public List listByStoreOrgId(Long storeOrgId) { return new ArrayList<>(); } +} diff --git a/src/main/java/io/as/modules/sys/service/impl/ShiroServiceImpl.java b/src/main/java/io/as/modules/sys/service/impl/ShiroServiceImpl.java new file mode 100644 index 0000000..a204a38 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/impl/ShiroServiceImpl.java @@ -0,0 +1,61 @@ +package io.as.modules.sys.service.impl; + +import io.as.common.utils.Constant; +import io.as.modules.sys.dao.SysMenuDao; +import io.as.modules.sys.dao.SysUserDao; +import io.as.modules.sys.dao.SysUserTokenDao; +import io.as.modules.sys.entity.SysMenuEntity; +import io.as.modules.sys.entity.SysUserEntity; +import io.as.modules.sys.entity.SysUserTokenEntity; +import io.as.modules.sys.service.ShiroService; +import io.as.modules.sys.service.SysUserService; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Service +public class ShiroServiceImpl implements ShiroService { + @Autowired + private SysMenuDao sysMenuDao; + @Autowired + private SysUserDao sysUserDao; + @Autowired + private SysUserTokenDao sysUserTokenDao; + @Autowired + private SysUserService sysUserService; + + @Override + public Set getUserPermissions(long userId) { + List permsList; + SysUserEntity user = sysUserService.getById(userId); + if (user != null && Constant.isSuperAdmin(user.getOrgId())) { + List menuList = sysMenuDao.selectList(null); + permsList = new ArrayList<>(menuList.size()); + for (SysMenuEntity menu : menuList) { + permsList.add(menu.getPerms()); + } + } else { + permsList = sysUserDao.queryAllPerms(userId); + } + Set permsSet = new HashSet<>(); + for (String perms : permsList) { + if (StringUtils.isBlank(perms)) { + continue; + } + permsSet.addAll(Arrays.asList(perms.trim().split(","))); + } + return permsSet; + } + + @Override + public SysUserTokenEntity queryByToken(String token) { + return sysUserTokenDao.queryByToken(token); + } + + @Override + public SysUserEntity queryUser(Long userId) { + return sysUserDao.selectById(userId); + } +} diff --git a/src/main/java/io/as/modules/sys/service/impl/StoreProfileServiceImpl.java b/src/main/java/io/as/modules/sys/service/impl/StoreProfileServiceImpl.java new file mode 100755 index 0000000..c533a20 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/impl/StoreProfileServiceImpl.java @@ -0,0 +1,14 @@ +package io.as.modules.sys.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.modules.sys.dao.StoreProfileDao; +import io.as.modules.sys.entity.StoreProfileEntity; +import io.as.modules.sys.service.StoreProfileService; +import org.springframework.stereotype.Service; + +/** + * 门店扩展表 + */ +@Service("storeProfileService") +public class StoreProfileServiceImpl extends ServiceImpl implements StoreProfileService { +} diff --git a/src/main/java/io/as/modules/sys/service/impl/SysCaptchaServiceImpl.java b/src/main/java/io/as/modules/sys/service/impl/SysCaptchaServiceImpl.java new file mode 100644 index 0000000..fbe50da --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/impl/SysCaptchaServiceImpl.java @@ -0,0 +1,49 @@ +package io.as.modules.sys.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.google.code.kaptcha.Producer; +import io.as.common.exception.ASException; +import io.as.common.utils.DateUtils; +import io.as.modules.sys.dao.SysCaptchaDao; +import io.as.modules.sys.entity.SysCaptchaEntity; +import io.as.modules.sys.service.SysCaptchaService; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.awt.image.BufferedImage; +import java.util.Date; + +@Service("sysCaptchaService") +public class SysCaptchaServiceImpl extends ServiceImpl implements SysCaptchaService { + @Autowired + private Producer producer; + + @Override + public BufferedImage getCaptcha(String uuid) { + if (StringUtils.isBlank(uuid)) { + throw new ASException("uuid不能为空"); + } + String code = producer.createText(); + SysCaptchaEntity captchaEntity = new SysCaptchaEntity(); + captchaEntity.setUuid(uuid); + captchaEntity.setCode(code); + captchaEntity.setExpireTime(DateUtils.addDateMinutes(new Date(), 5)); + this.save(captchaEntity); + return producer.createImage(code); + } + + @Override + public boolean validate(String uuid, String code) { + SysCaptchaEntity captchaEntity = this.getOne(new QueryWrapper().eq("uuid", uuid)); + if (captchaEntity == null) { + return false; + } + this.removeById(uuid); + if (captchaEntity.getCode().equalsIgnoreCase(code) && captchaEntity.getExpireTime().getTime() >= System.currentTimeMillis()) { + return true; + } + return false; + } +} diff --git a/src/main/java/io/as/modules/sys/service/impl/SysConfigServiceImpl.java b/src/main/java/io/as/modules/sys/service/impl/SysConfigServiceImpl.java new file mode 100755 index 0000000..5950212 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/impl/SysConfigServiceImpl.java @@ -0,0 +1,96 @@ +package io.as.modules.sys.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.google.gson.Gson; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.sys.dao.SysConfigDao; +import io.as.modules.sys.entity.SysConfigEntity; +import io.as.modules.sys.redis.SysConfigRedis; +import io.as.modules.sys.service.SysConfigService; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.Map; + +@Service("sysConfigService") +public class SysConfigServiceImpl extends ServiceImpl implements SysConfigService { + @Autowired + private SysConfigRedis sysConfigRedis; + + @Override + public PageUtils queryPage(Map params) { + String paramKey = (String)params.get("paramKey"); + + IPage page = this.page( + new Query().getPage(params), + new QueryWrapper() + .like(StringUtils.isNotBlank(paramKey),"param_key", paramKey) + .eq("status", 1) + ); + + return new PageUtils(page); + } + + @Override + public void saveConfig(SysConfigEntity config) { + this.save(config); + sysConfigRedis.saveOrUpdate(config); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void update(SysConfigEntity config) { + this.updateById(config); + sysConfigRedis.saveOrUpdate(config); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateValueByKey(String key, String value) { + baseMapper.updateValueByKey(key, value); + sysConfigRedis.delete(key); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteBatch(Long[] ids) { + for(Long id : ids){ + SysConfigEntity config = this.getById(id); + sysConfigRedis.delete(config.getParamKey()); + } + + this.removeByIds(Arrays.asList(ids)); + } + + @Override + public String getValue(String key) { + SysConfigEntity config = sysConfigRedis.get(key); + if(config == null){ + config = baseMapper.queryByKey(key); + sysConfigRedis.saveOrUpdate(config); + } + + return config == null ? null : config.getParamValue(); + } + + @Override + public T getConfigObject(String key, Class clazz) { + String value = getValue(key); + if(StringUtils.isNotBlank(value)){ + return new Gson().fromJson(value, clazz); + } + + try { + return clazz.newInstance(); + } catch (Exception e) { + throw new ASException("获取参数失败"); + } + } +} diff --git a/src/main/java/io/as/modules/sys/service/impl/SysMenuServiceImpl.java b/src/main/java/io/as/modules/sys/service/impl/SysMenuServiceImpl.java new file mode 100644 index 0000000..0f2a264 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/impl/SysMenuServiceImpl.java @@ -0,0 +1,91 @@ +package io.as.modules.sys.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.utils.Constant; +import io.as.common.utils.MapUtils; +import io.as.modules.sys.dao.SysMenuDao; +import io.as.modules.sys.entity.SysMenuEntity; +import io.as.modules.sys.entity.SysUserEntity; +import io.as.modules.sys.service.SysMenuService; +import io.as.modules.sys.service.SysRoleMenuService; +import io.as.modules.sys.service.SysUserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Service("sysMenuService") +public class SysMenuServiceImpl extends ServiceImpl implements SysMenuService { + @Autowired + private SysUserService sysUserService; + @Autowired + private SysRoleMenuService sysRoleMenuService; + + @Override + public List queryListParentId(Long parentId, List menuIdList) { + List menuList = queryListParentId(parentId); + if (menuIdList == null) { + return menuList; + } + List userMenuList = new ArrayList<>(); + for (SysMenuEntity menu : menuList) { + if (menuIdList.contains(menu.getId())) { + userMenuList.add(menu); + } + } + return userMenuList; + } + + @Override + public List queryListParentId(Long parentId) { + return baseMapper.queryListParentId(parentId); + } + + @Override + public List queryNotButtonList() { + return baseMapper.queryNotButtonList(); + } + + @Override + public List getUserMenuList(Long userId) { + SysUserEntity user = sysUserService.getById(userId); + if (user != null && Constant.isSuperAdmin(user.getOrgId())) { + return getMenuList(null); + } + List menuIdList = sysUserService.queryAllMenuId(userId); + return getMenuList(menuIdList); + } + + private List getMenuList(List menuIdList) { + if (menuIdList != null && menuIdList.isEmpty()) { + return new ArrayList<>(); + } + List menus = this.baseMapper.selectList(new QueryWrapper() + .in(Objects.nonNull(menuIdList), "id", menuIdList) + .in("type", 0, 1) + .apply("`show` = {0}", "10")); + Collections.sort(menus); + + HashMap menuMap = new HashMap<>(12); + for (SysMenuEntity s : menus) { + menuMap.put(s.getId(), s); + } + Iterator iterator = menus.iterator(); + while (iterator.hasNext()) { + SysMenuEntity menu = iterator.next(); + SysMenuEntity parent = menuMap.get(menu.getParentId()); + if (Objects.nonNull(parent)) { + parent.getList().add(menu); + iterator.remove(); + } + } + return menus; + } + + @Override + public void delete(Long menuId) { + this.removeById(menuId); + sysRoleMenuService.removeByMap(new MapUtils().put("menu_id", menuId)); + } +} diff --git a/src/main/java/io/as/modules/sys/service/impl/SysOrgServiceImpl.java b/src/main/java/io/as/modules/sys/service/impl/SysOrgServiceImpl.java new file mode 100644 index 0000000..132ac58 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/impl/SysOrgServiceImpl.java @@ -0,0 +1,2420 @@ +package io.as.modules.sys.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.exception.ASException; +import io.as.common.utils.Constant; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.sys.dao.SysOrgDao; +import io.as.modules.sys.dao.CardDao; +import io.as.modules.sys.dao.ContractActivationCodeDao; +import io.as.modules.sys.entity.SysOrgEntity; +import io.as.modules.sys.entity.StoreProfileEntity; +import io.as.modules.sys.entity.CardEntity; +import io.as.modules.sys.entity.ContractActivationCodeEntity; +import io.as.modules.sys.entity.ContractEntity; +import io.as.modules.sys.entity.SysRoleEntity; +import io.as.modules.sys.entity.SysUserEntity; +import io.as.modules.sys.entity.SysUserRoleEntity; +import io.as.modules.device.entity.DeviceEntity; +import io.as.modules.sys.service.SysOrgService; +import io.as.modules.sys.service.SysRoleService; +import io.as.modules.sys.service.SysUserRoleService; +import io.as.modules.sys.service.StoreProfileService; +import io.as.modules.sys.service.SysUserService; +import io.as.modules.sys.service.ContractService; +import io.as.modules.device.service.DeviceService; +import io.as.modules.sys.service.CardTransferService; +import io.as.modules.device.constants.DeviceConstants; +import io.as.modules.sys.constants.OrgCodeConstants; +import io.as.modules.sys.constants.OrgTypeRoleConstants; +import io.as.modules.sys.vo.CardStatsByTypeVo; +import io.as.modules.sys.vo.CardTypeDetailStatsVo; +import io.as.modules.sys.vo.OrgCardTypeAllocatableVo; +import io.as.modules.sys.vo.OrgCardStatsByTypeVo; +import io.as.modules.sys.vo.OrgCardStatsVo; +import io.as.modules.sys.vo.StoreCardTypeStatsVo; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Random; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 组织 + */ +@Service("sysOrgService") +public class SysOrgServiceImpl extends ServiceImpl implements SysOrgService { + + private static final String DELETE_FLAG_UNDEL = "UNDEL"; + private static final String DELETE_FLAG_DEL = "DEL"; + private static final String ORG_TYPE_PLATFORM = "PLATFORM"; + private static final String ORG_TYPE_CHANNEL = "CHANNEL"; + private static final String ORG_TYPE_SERVICE_PROVIDER = "SERVICE_PROVIDER"; + private static final String ORG_TYPE_STORE = "STORE"; + /** 与 card.usage_status:非 UNUSED 视为已使用 */ + private static final String CARD_USAGE_UNUSED = "UNUSED"; + private static final String CARD_USAGE_WAITING_MIGRATION = "WAITING_MIGRATION"; + + @Autowired + private SysUserService sysUserService; + + @Autowired + private StoreProfileService storeProfileService; + + @Autowired + private SysUserRoleService sysUserRoleService; + + @Autowired + private ContractService contractService; + + @Autowired + private SysRoleService sysRoleService; + + @Autowired + private CardDao cardDao; + + @Autowired + private ContractActivationCodeDao contractActivationCodeDao; + + @Autowired + @Lazy + private DeviceService deviceService; + + @Autowired + private CardTransferService cardTransferService; + + @Override + public PageUtils queryPage(Map params, Long operatorId) { + String name = (String) params.get("name"); + String type = (String) params.get("type"); + String status = (String) params.get("status"); + + QueryWrapper wrapper = new QueryWrapper() + .eq("delete_flag", DELETE_FLAG_UNDEL) + .like(StringUtils.isNotBlank(name), "name", name) + .eq(StringUtils.isNotBlank(type), "type", type) + .eq(StringUtils.isNotBlank(status), "status", status) + .orderByAsc("order_no", "id"); + + // 数据隔离:仅允许查看本人所属组织及其所有子级 + applyOrgScope(wrapper, operatorId); + + IPage page = this.page(new Query().getPage(params), wrapper); + List list = page.getRecords(); + // 填充父组织名称(门店列表即所属代理商) + fillParentOrgName(list); + // 搜索门店时关联 store_profile 扩展表 + fillStoreProfile(list); + // 关联 user 表,填充各组织下的登录账号 + fillLoginUsers(list); + // 该组织及子级拥有的卡数、已分配到门店的卡数(含子级) + fillCardCounts(list); + return new PageUtils(page); + } + + @Override + public PageUtils queryStoreSearchPage(Map params, Long operatorId) { + QueryWrapper wrapper = buildStoreSearchWrapper(params, operatorId); + if (wrapper == null) { + return new PageUtils(new Page(1, 10)); + } + IPage page = this.page(new Query().getPage(params), wrapper); + List list = page.getRecords(); + fillParentOrgName(list); + fillStoreProfile(list); + fillStoreCards(list); + fillActivationCodeGroup(list); + fillRootChannelInList(list); + return new PageUtils(page); + } + + @Override + public List listStoresInScope(Map params, Long operatorId) { + QueryWrapper wrapper = buildStoreSearchWrapper(params, operatorId); + if (wrapper == null) { + return new ArrayList<>(); + } + List list = this.list(wrapper); + if (list == null || list.isEmpty()) { + return new ArrayList<>(); + } + fillParentOrgName(list); + fillStoreProfile(list); + return list; + } + + /** + * 门店搜索/列表共用条件;无权限查看 scope org 时返回 null(调用方返回空分页或空列表) + * 可选 activationCode:按 contract_activation_code.code 模糊匹配(与 region 同时存在时取门店 ID 交集) + * 排序:按组织主键 id 降序(sys_org.id DESC) + */ + private QueryWrapper buildStoreSearchWrapper(Map params, Long operatorId) { + String name = (String) params.get("name"); + String status = (String) params.get("status"); + String region = (String) params.get("region"); + String activationCode = (String) params.get("activationCode"); + Long scopeOrgId = null; + Object orgIdParam = params.get("orgId"); + if (orgIdParam != null) { + if (orgIdParam instanceof Number) { + scopeOrgId = ((Number) orgIdParam).longValue(); + } else if (orgIdParam instanceof String && StringUtils.isNotBlank((String) orgIdParam)) { + try { + scopeOrgId = Long.parseLong(((String) orgIdParam).trim()); + } catch (NumberFormatException ignored) {} + } + } + + QueryWrapper wrapper = new QueryWrapper() + .eq("delete_flag", DELETE_FLAG_UNDEL) + .eq("type", ORG_TYPE_STORE) + .like(StringUtils.isNotBlank(name), "name", name) + .eq(StringUtils.isNotBlank(status), "status", status); + wrapper.orderByDesc("id"); + + if (scopeOrgId != null) { + if (!isOrgInScope(scopeOrgId, operatorId)) { + return null; + } + SysOrgEntity scopeOrg = this.getById(scopeOrgId); + if (scopeOrg == null || StringUtils.isBlank(scopeOrg.getMpath())) { + wrapper.eq("id", -1); + } else { + final Long finalScopeOrgId = scopeOrgId; + final String mpathVal = scopeOrg.getMpath(); + wrapper.and(w -> w.eq("id", finalScopeOrgId).or().apply("mpath LIKE CONCAT({0}, '%')", mpathVal)); + } + } else { + applyOrgScope(wrapper, operatorId); + } + + List regionOrgIds = null; + if (StringUtils.isNotBlank(region)) { + regionOrgIds = storeProfileService.lambdaQuery() + .eq(StoreProfileEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .like(StoreProfileEntity::getRegion, region.trim()) + .list() + .stream() + .map(StoreProfileEntity::getOrgId) + .distinct() + .collect(Collectors.toList()); + } + + List activationOrgIds = null; + if (StringUtils.isNotBlank(activationCode)) { + String acTrim = activationCode.trim(); + activationOrgIds = contractActivationCodeDao.selectList( + new LambdaQueryWrapper() + .eq(ContractActivationCodeEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .like(ContractActivationCodeEntity::getCode, acTrim) + .isNotNull(ContractActivationCodeEntity::getStoreOrgId)) + .stream() + .map(ContractActivationCodeEntity::getStoreOrgId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + } + + if (regionOrgIds != null || activationOrgIds != null) { + List idFilter; + if (regionOrgIds != null && activationOrgIds != null) { + Set activationSet = new HashSet<>(activationOrgIds); + idFilter = regionOrgIds.stream().filter(activationSet::contains).collect(Collectors.toList()); + } else if (regionOrgIds != null) { + idFilter = regionOrgIds; + } else { + idFilter = activationOrgIds; + } + if (idFilter == null || idFilter.isEmpty()) { + wrapper.eq("id", -1); + } else { + wrapper.in("id", idFilter); + } + } + return wrapper; + } + + /** + * 为门店列表填充:门店绑定卡总数 storeCards、已使用卡数 cardUsedCount(usage_status 非 UNUSED)、按卡类型统计 + */ + private void fillStoreCards(List list) { + if (list == null || list.isEmpty()) { + return; + } + List storeOrgIds = list.stream().map(SysOrgEntity::getId).distinct().collect(Collectors.toList()); + + // 统计总数 + QueryWrapper totalQw = new QueryWrapper<>(); + totalQw.select("org_id", "COUNT(*) AS cnt") + .eq("delete_flag", DELETE_FLAG_UNDEL) + .in("org_id", storeOrgIds) + .groupBy("org_id"); + Map totalByOrg = cardDao.selectMaps(totalQw).stream() + .collect(Collectors.toMap( + m -> ((Number) m.get("org_id")).longValue(), + m -> ((Number) m.get("cnt")).longValue())); + + // 统计已使用 + QueryWrapper usedQw = new QueryWrapper<>(); + usedQw.select("org_id", "COUNT(*) AS cnt") + .eq("delete_flag", DELETE_FLAG_UNDEL) + .in("org_id", storeOrgIds) + .and(w -> w.isNull("usage_status").or().notIn("usage_status", CARD_USAGE_UNUSED, CARD_USAGE_WAITING_MIGRATION)) + .groupBy("org_id"); + Map usedByOrg = cardDao.selectMaps(usedQw).stream() + .collect(Collectors.toMap( + m -> ((Number) m.get("org_id")).longValue(), + m -> ((Number) m.get("cnt")).longValue())); + + // 按卡类型统计 + Map> statsByOrg = fillStoreCardsByType(storeOrgIds); + + for (SysOrgEntity org : list) { + Long id = org.getId(); + org.setStoreCards(totalByOrg.getOrDefault(id, 0L)); + org.setCardUsedCount(usedByOrg.getOrDefault(id, 0L)); + org.setCardStatsByType(statsByOrg.getOrDefault(id, new ArrayList<>())); + } + } + + /** + * 按卡类型统计门店卡片使用情况 + */ + private Map> fillStoreCardsByType(List storeOrgIds) { + Map> result = new HashMap<>(); + if (storeOrgIds == null || storeOrgIds.isEmpty()) { + return result; + } + + // 卡类型映射 + Map cardTypeNameMap = new HashMap<>(); + cardTypeNameMap.put("YEAR", "年卡"); + cardTypeNameMap.put("QUARTER", "季卡"); + cardTypeNameMap.put("MONTH", "月卡"); + cardTypeNameMap.put("WEEK", "周卡"); + cardTypeNameMap.put("OTHER", "天卡"); + + // 按 org_id 和 card_type 统计总数 + QueryWrapper typeTotalQw = new QueryWrapper<>(); + typeTotalQw.select("org_id", "card_type", "COUNT(*) AS cnt") + .eq("delete_flag", DELETE_FLAG_UNDEL) + .in("org_id", storeOrgIds) + .groupBy("org_id", "card_type"); + Map totalByOrgType = cardDao.selectMaps(typeTotalQw).stream() + .collect(Collectors.toMap( + m -> m.get("org_id") + "_" + m.get("card_type"), + m -> ((Number) m.get("cnt")).longValue(), + (a, b) -> a)); + + // 按 org_id 和 card_type 统计已使用(usage_status 不为 UNUSED) + QueryWrapper typeUsedQw = new QueryWrapper<>(); + typeUsedQw.select("org_id", "card_type", "COUNT(*) AS cnt") + .eq("delete_flag", DELETE_FLAG_UNDEL) + .in("org_id", storeOrgIds) + .and(w -> w.isNull("usage_status").or().notIn("usage_status", CARD_USAGE_UNUSED, CARD_USAGE_WAITING_MIGRATION)) + .groupBy("org_id", "card_type"); + Map usedByOrgType = cardDao.selectMaps(typeUsedQw).stream() + .collect(Collectors.toMap( + m -> m.get("org_id") + "_" + m.get("card_type"), + m -> ((Number) m.get("cnt")).longValue(), + (a, b) -> a)); + + // 按 org_id 和 card_type 统计未使用(usage_status = UNUSED) + QueryWrapper typeUnusedQw = new QueryWrapper<>(); + typeUnusedQw.select("org_id", "card_type", "COUNT(*) AS cnt") + .eq("delete_flag", DELETE_FLAG_UNDEL) + .in("org_id", storeOrgIds) + .eq("usage_status", CARD_USAGE_UNUSED) + .groupBy("org_id", "card_type"); + Map unusedByOrgType = cardDao.selectMaps(typeUnusedQw).stream() + .collect(Collectors.toMap( + m -> m.get("org_id") + "_" + m.get("card_type"), + m -> ((Number) m.get("cnt")).longValue(), + (a, b) -> a)); + + // 为每个门店组装数据 + List cardTypes = Arrays.asList("YEAR", "QUARTER", "MONTH", "WEEK", "OTHER"); + for (Long orgId : storeOrgIds) { + List statsList = new ArrayList<>(); + for (String cardType : cardTypes) { + String key = orgId + "_" + cardType; + long totalCount = totalByOrgType.getOrDefault(key, 0L); + // 只返回有数据的类型 + if (totalCount > 0) { + long usedCount = usedByOrgType.getOrDefault(key, 0L); + long unusedCount = unusedByOrgType.getOrDefault(key, 0L); + StoreCardTypeStatsVo stats = new StoreCardTypeStatsVo( + cardType, + cardTypeNameMap.getOrDefault(cardType, cardType), + totalCount, + usedCount, + unusedCount + ); + statsList.add(stats); + } + } + result.put(orgId, statsList); + } + + return result; + } + + /** + * 为门店列表填充激活码组:按 card_type 分别填充到对应字段 + */ + private void fillActivationCodeGroup(List list) { + if (list == null || list.isEmpty()) { + return; + } + List storeOrgIds = list.stream() + .filter(o -> ORG_TYPE_STORE.equals(o.getType())) + .map(SysOrgEntity::getId) + .distinct() + .collect(Collectors.toList()); + if (storeOrgIds.isEmpty()) { + return; + } + List codes = contractActivationCodeDao.selectList( + new LambdaQueryWrapper() + .in(ContractActivationCodeEntity::getStoreOrgId, storeOrgIds) + .eq(ContractActivationCodeEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .orderByAsc(ContractActivationCodeEntity::getId)); + Map orgById = list.stream() + .filter(o -> o.getId() != null) + .collect(Collectors.toMap(SysOrgEntity::getId, o -> o, (a, b) -> a)); + Map> codesByStore = codes.stream() + .collect(Collectors.groupingBy( + ContractActivationCodeEntity::getStoreOrgId, + LinkedHashMap::new, + Collectors.toList())); + for (Long storeOrgId : storeOrgIds) { + SysOrgEntity org = orgById.get(storeOrgId); + if (org == null) { + continue; + } + Map> codesByType = codesByStore.getOrDefault(storeOrgId, Collections.emptyList()).stream() + .filter(c -> c.getCode() != null && !c.getCode().isEmpty()) + .collect(Collectors.groupingBy( + c -> c.getCardType() != null ? c.getCardType().toUpperCase() : "OTHER", + Collectors.mapping(ContractActivationCodeEntity::getCode, Collectors.toList()))); + org.setYearActivationCode(joinCodes(codesByType.get("YEAR"))); + org.setQuarterActivationCode(joinCodes(codesByType.get("QUARTER"))); + org.setMonthActivationCode(joinCodes(codesByType.get("MONTH"))); + org.setWeekActivationCode(joinCodes(codesByType.get("WEEK"))); + org.setOtherActivationCode(joinCodes(codesByType.get("OTHER"))); + } + } + + /** + * 将激活码列表用 / 拼接 + */ + private String joinCodes(List codes) { + if (codes == null || codes.isEmpty()) { + return null; + } + return String.join("/", codes); + } + + /** + * 为组织列表填充 parentOrgName、parentOrgCode(父组织名称与编码;门店即所属代理商) + */ + private void fillParentOrgName(List list) { + if (list == null || list.isEmpty()) { + return; + } + List parentIds = list.stream() + .map(SysOrgEntity::getParentId) + .filter(id -> id != null) + .distinct() + .collect(Collectors.toList()); + if (parentIds.isEmpty()) { + return; + } + Map parentMap = this.listByIds(parentIds).stream() + .collect(Collectors.toMap(SysOrgEntity::getId, o -> o, (a, b) -> a)); + for (SysOrgEntity org : list) { + if (org.getParentId() != null) { + SysOrgEntity parent = parentMap.get(org.getParentId()); + if (parent != null) { + org.setParentOrgName(parent.getName()); + org.setParentOrgCode(parent.getCode()); + } + } + } + } + + /** + * 为门店类型组织填充 store_profile 扩展信息(列表搜索门店时关联扩展表) + */ + private void fillStoreProfile(List list) { + if (list == null || list.isEmpty()) { + return; + } + List storeOrgIds = list.stream() + .filter(o -> ORG_TYPE_STORE.equals(o.getType())) + .map(SysOrgEntity::getId) + .distinct() + .collect(Collectors.toList()); + if (storeOrgIds.isEmpty()) { + return; + } + Map profileMap = storeProfileService.listByIds(storeOrgIds).stream() + .collect(Collectors.toMap(StoreProfileEntity::getOrgId, p -> p, (a, b) -> a)); + for (SysOrgEntity org : list) { + if (ORG_TYPE_STORE.equals(org.getType())) { + org.setStoreProfile(profileMap.get(org.getId())); + } + } + } + + /** + * 为组织列表关联 sys_user 表,将登录账号的 username 填到 loginMobile(列表搜索时展示) + */ + private void fillLoginUsers(List list) { + if (list == null || list.isEmpty()) { + return; + } + List orgIds = list.stream().map(SysOrgEntity::getId).distinct().collect(Collectors.toList()); + QueryWrapper userWrapper = new QueryWrapper().in("org_id", orgIds); + List users = sysUserService.list(userWrapper); + Map> orgUserMap = users.stream().collect(Collectors.groupingBy(SysUserEntity::getOrgId)); + for (SysOrgEntity org : list) { + List orgUsers = orgUserMap.getOrDefault(org.getId(), Collections.emptyList()); + String loginMobile = orgUsers.stream().map(SysUserEntity::getUsername).collect(Collectors.joining(",")); + org.setLoginMobile(orgUsers.isEmpty() ? null : loginMobile); + } + } + + /** + * 为组织列表填充:该组织及子级拥有的卡数量、已分配到门店的卡数量(含子级) + */ + private void fillCardCounts(List list) { + if (list == null || list.isEmpty()) { + return; + } + SysOrgDao dao = baseMapper; + for (SysOrgEntity org : list) { + Long cardCount = dao.countCardInSubtree(org.getId(), org.getMpath()); + Long storeAllocated = dao.countStoreAllocatedCardInSubtree(org.getId(), org.getMpath()); + org.setCardCountIncludingChildren(cardCount != null ? cardCount : 0L); + org.setStoreAllocatedCardCountIncludingChildren(storeAllocated != null ? storeAllocated : 0L); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void saveOrg(SysOrgEntity org, Long operatorId) { + // 渠道的父组织默认为 1(平台),无需前端传 parentId + if (ORG_TYPE_CHANNEL.equals(org.getType()) && org.getParentId() == null) { + org.setParentId(1L); + } + // 数据隔离:父组织必须在本人所属组织及其子级范围内 + if (org.getParentId() != null && !isOrgInScope(org.getParentId(), operatorId)) { + throw new ASException("无权限在该组织下创建子组织"); + } + // 若本次会创建登录账号,先校验账号是否已存在,避免保存组织后再报错 + if ((ORG_TYPE_CHANNEL.equals(org.getType()) || ORG_TYPE_SERVICE_PROVIDER.equals(org.getType()) || ORG_TYPE_STORE.equals(org.getType())) + && StringUtils.isNotBlank(org.getLoginMobile()) + && StringUtils.isNotBlank(org.getLoginPassword())) { + String loginAccount = org.getLoginMobile().trim(); + SysUserEntity existUser = sysUserService.queryByUserName(loginAccount); + if (existUser != null) { + throw new ASException("登录账号已存在:" + loginAccount); + } + } + // 组织类型与父级关系校验:渠道只能属于平台,代理商只能属于渠道或代理商,门店只能属于渠道或代理商(不允许平台) + validateOrgParentType(org); + // can_hold_inventory:渠道默认可持仓;代理商允许前端配置;门店等其他类型强制不允许 + if (ORG_TYPE_CHANNEL.equals(org.getType())) { + org = validateAndFillChannelCode(org); + org.setCanHoldInventory(true); + } else if (ORG_TYPE_SERVICE_PROVIDER.equals(org.getType())) { + if (org.getCanHoldInventory() == null) { + org.setCanHoldInventory(false); + } + } else { + org.setCanHoldInventory(false); + } + // 代理商/门店:未传组织编码时,生成6位大写字母数字混合编码 + if (ORG_TYPE_SERVICE_PROVIDER.equals(org.getType()) || ORG_TYPE_STORE.equals(org.getType())) { + if (StringUtils.isBlank(org.getCode())) { + org.setCode(OrgCodeConstants.generateOrgCodeWithChannelPrefix()); + } + } + normalizeServiceProviderExclusiveFields(org); + org.setCreatedAt(new Date()); + org.setCreateBy(operatorId); + org.setDeleteFlag(DELETE_FLAG_UNDEL); + this.save(org); + // 设置 mpath + buildMpath(org); + this.updateById(org); + // 类型为渠道、代理商或门店时,可根据前端传入的电话和密码创建登录用户(账号密码为可选) + if ((ORG_TYPE_CHANNEL.equals(org.getType()) || ORG_TYPE_SERVICE_PROVIDER.equals(org.getType()) || ORG_TYPE_STORE.equals(org.getType())) + && StringUtils.isNotBlank(org.getLoginMobile()) + && StringUtils.isNotBlank(org.getLoginPassword())) { + createOrgLoginUser(org, operatorId); + } + // 门店需同步写入 store_profile 扩展表 + if (ORG_TYPE_STORE.equals(org.getType())) { + saveOrUpdateStoreProfile(org.getId(), org.getStoreProfile(), operatorId, true); + // PRD REQ-4.2.1:门店创建时自动生成唯一合同 + contractService.createForStore(org.getId()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateOrg(SysOrgEntity org, Long operatorId) { + SysOrgEntity existing = this.getById(org.getId()); + if (existing == null) { + throw new ASException("组织不存在"); + } + String oldMpath = existing.getMpath(); + Long oldParentId = existing.getParentId(); + boolean parentChanged = !Objects.equals(oldParentId, org.getParentId()); + Long oldChannelOrgId = getChannelOrgIdForOrg(existing.getId()); + + // 数据隔离:只能修改本人所属组织及其子级 + if (!isOrgInScope(org.getId(), operatorId)) { + throw new ASException("无权限修改该组织"); + } + if (org.getParentId() != null && !isOrgInScope(org.getParentId(), operatorId)) { + throw new ASException("无权限将父组织设置为该组织"); + } + // 组织类型与父级关系校验 + validateOrgParentType(org); + normalizeServiceProviderExclusiveFields(org); + // can_hold_inventory:渠道、代理商可配置可持仓;未传时保持原值;其他类型强制不允许 + if (ORG_TYPE_CHANNEL.equals(org.getType()) || ORG_TYPE_SERVICE_PROVIDER.equals(org.getType())) { + if (org.getCanHoldInventory() == null) { + Boolean ex = existing.getCanHoldInventory(); + org.setCanHoldInventory(ex != null ? ex : false); + } + } else { + org.setCanHoldInventory(false); + } + // 渠道:组织编码与渠道专属前缀创建后不可变,以库为准覆盖前端传值 + if (ORG_TYPE_CHANNEL.equals(org.getType())) { + if (existing != null) { + org.setCode(existing.getCode()); + org.setActivationPrefix(existing.getActivationPrefix()); + } + } + org.setUpdatedAt(new Date()); + org.setUpdateBy(operatorId); + buildMpath(org); + this.updateById(org); + + // 若发生父级变更,需要同步更新该组织及其子树的 mpath 前缀 + if (parentChanged && StringUtils.isNotBlank(oldMpath) && StringUtils.isNotBlank(org.getMpath()) && !oldMpath.equals(org.getMpath())) { + updateDescendantMpath(org.getId(), oldMpath, org.getMpath(), operatorId); + } + + // 若发生父级变更,且所属渠道发生变化,需要处理卡的流转 + if (parentChanged) { + Long newChannelOrgId = getChannelOrgIdForOrg(org.getId()); + if (!Objects.equals(oldChannelOrgId, newChannelOrgId)) { + // 渠道发生变化,需要将该组织及其子组织下的卡流转到新渠道 + transferCardsForOrgChange(org.getId(), org.getMpath(), oldChannelOrgId, newChannelOrgId, operatorId); + } + } + + // 门店需同步更新 store_profile 扩展表 + if (ORG_TYPE_STORE.equals(org.getType())) { + saveOrUpdateStoreProfile(org.getId(), org.getStoreProfile(), operatorId, false); + } + // 渠道/代理商/门店:支持在修改时设置或更新登录账号(账号密码为可选) + if ((ORG_TYPE_CHANNEL.equals(org.getType()) || ORG_TYPE_SERVICE_PROVIDER.equals(org.getType()) || ORG_TYPE_STORE.equals(org.getType())) + && (StringUtils.isNotBlank(org.getLoginMobile()) || StringUtils.isNotBlank(org.getLoginPassword()))) { + saveOrUpdateOrgLoginUser(org, operatorId); + } + } + + /** + * 组织父级变更后,同步更新该节点及其后代的 mpath 前缀。 + * mpath 形如:根节点 "id.",子节点 "父mpath + id.",因此移动节点只需要替换前缀部分即可。 + */ + private void updateDescendantMpath(Long orgId, String oldMpath, String newMpath, Long operatorId) { + if (orgId == null || StringUtils.isBlank(oldMpath) || StringUtils.isBlank(newMpath)) { + return; + } + Date now = new Date(); + List descendants = this.lambdaQuery() + .eq(SysOrgEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .ne(SysOrgEntity::getId, orgId) + .apply("mpath LIKE CONCAT({0}, '%')", oldMpath) + .list(); + if (descendants == null || descendants.isEmpty()) { + return; + } + for (SysOrgEntity d : descendants) { + String dm = d.getMpath(); + if (StringUtils.isNotBlank(dm) && dm.startsWith(oldMpath)) { + d.setMpath(newMpath + dm.substring(oldMpath.length())); + d.setUpdatedAt(now); + d.setUpdateBy(operatorId); + } + } + this.updateBatchById(descendants); + } + + /** + * 组织渠道变更后,处理该组织及其子组织下的卡流转。 + * 将卡的 orgId 从旧渠道更新为新渠道,并记录流转历史。 + * @param orgId 变更的组织ID + * @param mpath 变更组织的mpath + * @param fromOrgId 来源组织ID(旧渠道ID) + * @param toOrgId 目标组织ID(新渠道ID) + * @param operatorId 操作人ID + */ + private void transferCardsForOrgChange(Long orgId, String mpath, Long fromOrgId, Long toOrgId, Long operatorId) { + if (orgId == null || StringUtils.isBlank(mpath)) { + return; + } + if (Objects.equals(fromOrgId, toOrgId)) { + return; + } + + // 查询该组织及其子组织下的所有卡 + List orgIds = this.lambdaQuery() + .eq(SysOrgEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .and(w -> w.eq(SysOrgEntity::getId, orgId).or().apply("mpath LIKE CONCAT({0}, '%')", mpath)) + .list() + .stream() + .map(SysOrgEntity::getId) + .collect(Collectors.toList()); + + if (orgIds.isEmpty()) { + return; + } + + // 查询这些组织下的所有卡 + List cards = cardDao.selectList( + new QueryWrapper() + .in("org_id", orgIds) + .eq("delete_flag", DELETE_FLAG_UNDEL) + ); + + if (cards == null || cards.isEmpty()) { + return; + } + + Date now = new Date(); + List cardIds = new ArrayList<>(); + + // 收集卡ID并批量更新卡的orgId + for (CardEntity card : cards) { + cardIds.add(card.getId()); + } + + // 批量更新卡的orgId和updatedAt + if (!cardIds.isEmpty()) { + cardDao.update(null, + new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper() + .in("id", cardIds) + .set("org_id", toOrgId) + .set("updated_at", now) + ); + } + + // 记录卡流转历史 + cardTransferService.batchRecordTransfer(cardIds, fromOrgId, toOrgId, "ORG_CHANGE"); + } + + /** + * 所属渠道发生变化后:同步组织编码(code)。 + * 规则:非渠道(SERVICE_PROVIDER/STORE)code = 所属渠道 activationPrefix + 6位随机。 + */ + private void updateOrgCodeForSubtreeByChannelPrefix(Long orgId, String mpath, String channelActivationPrefix, Long operatorId) { + if (orgId == null || StringUtils.isBlank(mpath)) { + return; + } + if (StringUtils.isBlank(channelActivationPrefix)) { + return; + } + String prefix = channelActivationPrefix.trim().toUpperCase(); + if (prefix.length() != OrgCodeConstants.CHANNEL_PREFIX_LENGTH) { + // 渠道 activationPrefix 按 PRD 必须为 2 位;此处不做静默兜底 + throw new ASException("渠道激活码前缀(activationPrefix)无效:" + prefix); + } + + QueryWrapper wrapper = new QueryWrapper() + .eq("delete_flag", DELETE_FLAG_UNDEL) + .and(w -> w.eq("id", orgId).or().apply("mpath LIKE CONCAT({0}, '%')", mpath)); + List nodes = this.list(wrapper); + if (nodes == null || nodes.isEmpty()) { + return; + } + Date now = new Date(); + List toUpdate = new ArrayList<>(); + for (SysOrgEntity n : nodes) { + if (n == null || StringUtils.isBlank(n.getType())) continue; + if (ORG_TYPE_SERVICE_PROVIDER.equals(n.getType()) || ORG_TYPE_STORE.equals(n.getType())) { + n.setCode(OrgCodeConstants.generateOrgCodeWithChannelPrefix()); + n.setUpdatedAt(now); + n.setUpdateBy(operatorId); + toUpdate.add(n); + } + } + if (!toUpdate.isEmpty()) { + this.updateBatchById(toUpdate); + } + } + + /** + * 所属渠道发生变化后:同步门店下权益激活码(contract_activation_code.code)。 + * 激活码规则:渠道 activationPrefix(2位)+5位随机(默认前缀 AC)。 + */ + private void regenerateActivationCodesForStoreSubtree(Long orgId, String mpath, String channelActivationPrefix, Long operatorId) { + if (orgId == null || StringUtils.isBlank(mpath)) { + return; + } + Date now = new Date(); + Random random = new Random(); + + // 1) 找出子树内所有门店组织ID + QueryWrapper storeWrapper = new QueryWrapper() + .eq("delete_flag", DELETE_FLAG_UNDEL) + .eq("type", ORG_TYPE_STORE) + .and(w -> w.eq("id", orgId).or().apply("mpath LIKE CONCAT({0}, '%')", mpath)); + List stores = this.list(storeWrapper); + if (stores == null || stores.isEmpty()) { + return; + } + + // 2) 遍历门店合同 -> 更新对应激活码记录 + // contract_activation_code.code 的生成依赖“合同所属门店当前渠道前缀”,因此这里以子树新渠道前缀生成。 + List toUpdate = new ArrayList<>(); + for (SysOrgEntity store : stores) { + if (store == null || store.getId() == null) continue; + List contracts = contractService.listByStoreOrgId(store.getId()); + if (contracts == null || contracts.isEmpty()) continue; + for (ContractEntity c : contracts) { + if (c == null || c.getId() == null) continue; + if (StringUtils.isNotBlank(c.getDeleteFlag()) && DELETE_FLAG_DEL.equals(c.getDeleteFlag())) continue; + List cacs = contractActivationCodeDao.selectList( + new LambdaQueryWrapper() + .eq(ContractActivationCodeEntity::getContractId, c.getId()) + .eq(ContractActivationCodeEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + ); + if (cacs == null || cacs.isEmpty()) continue; + for (ContractActivationCodeEntity cac : cacs) { + if (cac == null) continue; + String newCode = generateActivationCodeByChannelPrefix(channelActivationPrefix, random); + cac.setCode(newCode); + cac.setUpdatedAt(now); + toUpdate.add(cac); + } + } + } + if (!toUpdate.isEmpty()) { + for (ContractActivationCodeEntity cac : toUpdate) { + contractActivationCodeDao.updateById(cac); + } + } + } + + private String generateActivationCodeByChannelPrefix(String channelActivationPrefix, Random random) { + String prefix = "AC"; + if (StringUtils.isNotBlank(channelActivationPrefix)) { + prefix = channelActivationPrefix.trim().toUpperCase(); + if (prefix.length() > 2) prefix = prefix.substring(0, 2); + else if (prefix.length() < 2) prefix = StringUtils.rightPad(prefix, 2, 'A'); + } + String allowed = OrgCodeConstants.CHANNEL_PREFIX_ALLOWED_CHARS; + StringBuilder sb = new StringBuilder(5); + for (int i = 0; i < 5; i++) { + sb.append(allowed.charAt(random.nextInt(allowed.length()))); + } + return prefix + sb; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteBatch(Long[] ids, Long operatorId) { + for (Long id : ids) { + if (!isOrgInScope(id, operatorId)) { + throw new ASException("无权限删除组织"); + } + } + Set deletingIds = new HashSet<>(Arrays.asList(ids)); + // 检查:渠道/代理商下仍有未删除的下级组织或门店时,禁止删除(同批次一并删除的下级除外) + for (Long id : ids) { + SysOrgEntity org = this.getById(id); + if (org == null) { + continue; + } + if (!ORG_TYPE_CHANNEL.equals(org.getType()) && !ORG_TYPE_SERVICE_PROVIDER.equals(org.getType())) { + continue; + } + List undeletedBelow; + String mpath = org.getMpath(); + if (StringUtils.isNotBlank(mpath)) { + undeletedBelow = this.list(new QueryWrapper() + .eq("delete_flag", DELETE_FLAG_UNDEL) + .ne("id", id) + .apply("mpath LIKE CONCAT({0}, '%')", mpath)); + } else { + undeletedBelow = this.lambdaQuery() + .eq(SysOrgEntity::getParentId, id) + .eq(SysOrgEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .list(); + } + if (undeletedBelow != null) { + for (SysOrgEntity sub : undeletedBelow) { + if (sub != null && !deletingIds.contains(sub.getId())) { + throw new ASException("代理商或渠道下存在未删除的下级组织或门店,请先删除下级后再删除本组织"); + } + } + } + } + // 检查:如果是门店类型且下有绑定设备,或存在指定使用状态的卡,禁止删除 + List storeDeleteBlockingCardUsage = Arrays.asList( + DeviceConstants.UNUSED, + DeviceConstants.BOUND, + DeviceConstants.USAGE_ACTIVE, + DeviceConstants.USAGE_ACTIVATING, + DeviceConstants.USAGE_ACTIVATION_FAILED, + DeviceConstants.USAGE_WAITING_MIGRATION); + for (Long id : ids) { + SysOrgEntity org = this.getById(id); + if (org != null && ORG_TYPE_STORE.equals(org.getType())) { + long deviceCount = deviceService.lambdaQuery() + .eq(DeviceEntity::getStoreOrgId, id) + .eq(DeviceEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .count(); + if (deviceCount > 0) { + throw new ASException("门店下存在绑定的设备,无法删除"); + } + long blockingCardCount = cardDao.selectCount(new LambdaQueryWrapper() + .eq(CardEntity::getOrgId, id) + .eq(CardEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .in(CardEntity::getUsageStatus, storeDeleteBlockingCardUsage)); + if (blockingCardCount > 0) { + throw new ASException("门店下存在卡(未使用/已绑定/激活中/激活失败/已激活/待迁移),无法删除"); + } + } + } + // 删除这些组织下的用户及其 sys_user_role 关联 + List userIds = sysUserService.lambdaQuery() + .in(SysUserEntity::getOrgId, Arrays.asList(ids)) + .list() + .stream() + .map(SysUserEntity::getId) + .collect(Collectors.toList()); + if (!userIds.isEmpty()) { + sysUserRoleService.remove(new LambdaQueryWrapper().in(SysUserRoleEntity::getUserId, userIds)); + sysUserService.deleteBatch(userIds.toArray(new Long[0])); + } + // 逻辑删除组织 + Date now = new Date(); + for (Long id : ids) { + SysOrgEntity org = new SysOrgEntity(); + org.setId(id); + org.setDeleteFlag(DELETE_FLAG_DEL); + org.setUpdatedAt(now); + org.setUpdateBy(operatorId); + this.updateById(org); + } + } + + @Override + public boolean isOrgInScope(Long orgId, Long operatorId) { + if (orgId == null || operatorId == null) { + return false; + } + SysUserEntity user = sysUserService.getById(operatorId); + if (user == null) { + return false; + } + // 超级管理员或 orgId 为空(视为超级管理员):可查看全部数据,不限制 + if (Constant.isSuperAdmin(user.getOrgId())) { + return true; + } + SysOrgEntity userOrg = this.getById(user.getOrgId()); + if (userOrg == null || StringUtils.isBlank(userOrg.getMpath())) { + return false; + } + SysOrgEntity target = this.getById(orgId); + if (target == null || StringUtils.isBlank(target.getMpath())) { + return false; + } + // 目标组织 mpath 以当前用户组织 mpath 开头,则在其范围内(本人或子级) + return target.getMpath().startsWith(userOrg.getMpath()); + } + + @Override + public List listDescendantStoreOrgIds(Long orgId) { + if (orgId == null) { + return new ArrayList<>(); + } + SysOrgEntity org = this.getById(orgId); + if (org == null) { + return new ArrayList<>(); + } + List descendantIds = getDescendantOrgIds(orgId, org.getMpath()); + if (descendantIds == null || descendantIds.isEmpty()) { + return new ArrayList<>(); + } + return this.lambdaQuery() + .select(SysOrgEntity::getId) + .eq(SysOrgEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .eq(SysOrgEntity::getType, ORG_TYPE_STORE) + .in(SysOrgEntity::getId, descendantIds) + .list() + .stream() + .map(SysOrgEntity::getId) + .distinct() + .collect(Collectors.toList()); + } + + @Override + public List queryListByParentId(Long parentId) { + return baseMapper.queryListByParentId(parentId); + } + + @Override + public List queryOrgTree(Long operatorId) { + // 1. 一次查询全部在数据范围内的组织(未删除),在内存中构建树 + QueryWrapper wrapper = new QueryWrapper() + .eq("delete_flag", DELETE_FLAG_UNDEL) + .orderByAsc("order_no", "id"); + applyOrgScope(wrapper, operatorId); + List all = this.list(wrapper); + if (all == null || all.isEmpty()) { + return new ArrayList<>(); + } + // 仅启用的组织参与树展示 + List activeList = all.stream() + .filter(o -> "ACTIVE".equals(o.getStatus())) + .collect(Collectors.toList()); + // 2. 按 parentId 分组(null 表示根) + Map> parentIdToChildren = activeList.stream() + .collect(Collectors.groupingBy(o -> o.getParentId() == null ? -1L : o.getParentId())); + // 3. 确定树根并递归挂载子节点 + SysUserEntity user = sysUserService.getById(operatorId); + Long userOrgId = user == null ? null : user.getOrgId(); + boolean useAllRoots = useGlobalOrgTreeRoots(operatorId, userOrgId, null); + if (useAllRoots) { + List roots = parentIdToChildren.getOrDefault(-1L, Collections.emptyList()); + roots.forEach(root -> buildTreeChildren(root, parentIdToChildren)); + return roots; + } + // 非超管:树根为当前用户所属组织,只展示该节点及其子级 + SysOrgEntity root = activeList.stream().filter(o -> o.getId().equals(userOrgId)).findFirst().orElse(null); + if (root == null) { + return new ArrayList<>(); + } + buildTreeChildren(root, parentIdToChildren); + List result = new ArrayList<>(); + result.add(root); + return result; + } + + @Override + public List queryOrgTreeExcludeStore(Long operatorId) { + return queryOrgTreeExcludeStore(operatorId, "ACTIVE"); + } + + @Override + public List queryOrgTreeExcludeStore(Long operatorId, String status) { + QueryWrapper wrapper = new QueryWrapper() + .eq("delete_flag", DELETE_FLAG_UNDEL) + .ne("type", ORG_TYPE_STORE) + .ne("type", ORG_TYPE_PLATFORM) + .orderByAsc("order_no", "id"); + applyOrgScope(wrapper, operatorId); + List all = this.list(wrapper); + if (all == null || all.isEmpty()) { + return new ArrayList<>(); + } + SysUserEntity user = sysUserService.getById(operatorId); + Long userOrgId = user == null ? null : user.getOrgId(); + SysOrgEntity bindOrg = userOrgId != null ? this.getById(userOrgId) : null; + boolean operatorIsAgent = bindOrg != null && ORG_TYPE_SERVICE_PROVIDER.equals(bindOrg.getType()); + // 代理商登录:仅代理商节点挂树,且不按 status 过滤,保证返回自身及全部下级代理商(含非启用) + List workingList; + if (operatorIsAgent) { + workingList = all.stream() + .filter(o -> ORG_TYPE_SERVICE_PROVIDER.equals(o.getType())) + .collect(Collectors.toList()); + } else { + workingList = StringUtils.isNotBlank(status) + ? all.stream().filter(o -> status.equals(o.getStatus())).collect(Collectors.toList()) + : all; + } + boolean useAllChannelRoots = useGlobalOrgTreeRoots(operatorId, userOrgId, bindOrg); + if (useAllChannelRoots) { + return buildGlobalOrgTreeExcludeStore(workingList, status); + } + Map> parentIdToChildren = workingList.stream() + .collect(Collectors.groupingBy(o -> o.getParentId() == null ? -1L : o.getParentId())); + SysOrgEntity root = workingList.stream().filter(o -> o.getId().equals(userOrgId)).findFirst().orElse(null); + if (root == null) { + return new ArrayList<>(); + } + buildTreeChildren(root, parentIdToChildren); + fillRootChannelInTree(Collections.singletonList(root)); + List result = new ArrayList<>(); + result.add(root); + return result; + } + + /** + * 全局组织树(超管 / orgId 为空):平台为顶层根,其下为渠道→代理商;无平台数据时退回为「以渠道为根」。 + */ + private List buildGlobalOrgTreeExcludeStore(List workingList, String status) { + Map byId = new LinkedHashMap<>(); + for (SysOrgEntity p : listPlatformOrgsForGlobalTree(status)) { + byId.put(p.getId(), p); + } + for (SysOrgEntity o : workingList) { + byId.put(o.getId(), o); + } + List merged = new ArrayList<>(byId.values()); + Map> parentIdToChildren = merged.stream() + .collect(Collectors.groupingBy(o -> o.getParentId() == null ? -1L : o.getParentId())); + List roots = merged.stream() + .filter(o -> ORG_TYPE_PLATFORM.equals(o.getType())) + .collect(Collectors.toList()); + if (roots.isEmpty()) { + roots = workingList.stream() + .filter(o -> ORG_TYPE_CHANNEL.equals(o.getType())) + .collect(Collectors.toList()); + } + roots.forEach(root -> buildTreeChildren(root, parentIdToChildren)); + fillRootChannelInTree(roots); + return roots; + } + + /** 未删除的平台组织;status 非空时仅保留该状态 */ + private List listPlatformOrgsForGlobalTree(String status) { + LambdaQueryWrapper q = new LambdaQueryWrapper() + .eq(SysOrgEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .eq(SysOrgEntity::getType, ORG_TYPE_PLATFORM) + .orderByAsc(SysOrgEntity::getOrderNo, SysOrgEntity::getId); + if (StringUtils.isNotBlank(status)) { + q.eq(SysOrgEntity::getStatus, status); + } + List list = this.list(q); + return list == null ? Collections.emptyList() : list; + } + + @Override + public List listOrgByScope(Long operatorId) { + QueryWrapper wrapper = new QueryWrapper() + .eq("delete_flag", DELETE_FLAG_UNDEL) + .eq("status", "ACTIVE") + .orderByAsc("order_no", "id"); + applyOrgScope(wrapper, operatorId); + List list = this.list(wrapper); + if (list == null || list.isEmpty()) { + return new ArrayList<>(); + } + fillParentOrgName(list); + return list; + } + + @Override + public List listChannelsInScope(Long operatorId) { + QueryWrapper wrapper = new QueryWrapper() + .eq("delete_flag", DELETE_FLAG_UNDEL) + .eq("type", ORG_TYPE_CHANNEL) + .eq("status", "ACTIVE") + .orderByAsc("order_no", "id"); + applyOrgScope(wrapper, operatorId); + List list = this.list(wrapper); + if (list == null || list.isEmpty()) { + return new ArrayList<>(); + } + fillParentOrgName(list); + return list; + } + + @Override + public List queryAgentTreeUnderChannel(Long channelOrgId, Long operatorId) { + if (channelOrgId == null || operatorId == null) { + return new ArrayList<>(); + } + if (!isOrgInScope(channelOrgId, operatorId)) { + return new ArrayList<>(); + } + SysOrgEntity channel = this.getById(channelOrgId); + if (channel == null || !DELETE_FLAG_UNDEL.equals(channel.getDeleteFlag()) || !ORG_TYPE_CHANNEL.equals(channel.getType())) { + return new ArrayList<>(); + } + if (StringUtils.isBlank(channel.getMpath())) { + return new ArrayList<>(); + } + QueryWrapper wrapper = new QueryWrapper() + .eq("delete_flag", DELETE_FLAG_UNDEL) + .eq("type", ORG_TYPE_SERVICE_PROVIDER) + .eq("status", "ACTIVE") + .apply("mpath LIKE CONCAT({0}, '%')", channel.getMpath()) + .orderByAsc("order_no", "id"); + applyOrgScope(wrapper, operatorId); + List flat = this.list(wrapper); + if (flat == null || flat.isEmpty()) { + return new ArrayList<>(); + } + List tree = buildOrgTreeFromFlatList(flat); + fillRootChannelInTree(tree); + return tree; + } + + /** + * 为树中每个节点填充:上级组织=根渠道名称,上级组织编码=渠道编码(所属渠道的 name/code) + */ + private void fillRootChannelInTree(List roots) { + if (roots == null || roots.isEmpty()) return; + Set orgIds = new HashSet<>(); + collectOrgIdsFromTree(roots, orgIds); + Map orgIdToChannelId = new HashMap<>(); + Set idsInMpath = new HashSet<>(); + for (Long id : orgIds) { + Long cid = getChannelOrgIdForOrg(id); + if (cid != null) orgIdToChannelId.put(id, cid); + // 提取 mpath 中出现的所有 ID(用于后续查询) + SysOrgEntity org = this.getById(id); + if (org != null && StringUtils.isNotBlank(org.getMpath())) { + String[] parts = org.getMpath().split("\\."); + for (String part : parts) { + if (StringUtils.isNotBlank(part)) { + try { + idsInMpath.add(Long.parseLong(part)); + } catch (NumberFormatException e) { + // 忽略非数字部分 + } + } + } + } + } + if (orgIdToChannelId.isEmpty()) return; + // 获取所有需要的组织数据(包括渠道及 mpath 中出现的 ID) + Set allNeededIds = new HashSet<>(orgIdToChannelId.values()); + allNeededIds.addAll(idsInMpath); + Map channelMap = this.listByIds(new ArrayList<>(allNeededIds)).stream() + .collect(Collectors.toMap(SysOrgEntity::getId, o -> o, (a, b) -> a)); + for (SysOrgEntity node : roots) { + applyRootChannelToNode(node, orgIdToChannelId, channelMap); + } + } + + private void collectOrgIdsFromTree(List nodes, Set orgIds) { + if (nodes == null) return; + for (SysOrgEntity n : nodes) { + if (n != null && n.getId() != null) { + orgIds.add(n.getId()); + collectOrgIdsFromTree(n.getChildren(), orgIds); + } + } + } + + private void applyRootChannelToNode(SysOrgEntity node, Map orgIdToChannelId, Map channelMap) { + if (node == null) return; + Long cid = orgIdToChannelId.get(node.getId()); + if (cid != null) { + SysOrgEntity ch = channelMap.get(cid); + if (ch != null) { + node.setParentOrgName(ch.getName()); + node.setParentOrgCode(ch.getCode()); + // 从 mpath 第二个 ID 获取 activationPrefix + String mpath = node.getMpath(); + if (StringUtils.isNotBlank(mpath)) { + String[] parts = mpath.split("\\."); + if (parts.length >= 2 && StringUtils.isNotBlank(parts[1])) { + try { + Long secondOrgId = Long.parseLong(parts[1]); + SysOrgEntity secondOrg = channelMap.get(secondOrgId); + if (secondOrg != null) { + node.setRootChannelActivationPrefix(secondOrg.getActivationPrefix()); + } + } catch (NumberFormatException e) { + // mpath 格式异常,保持原逻辑 + node.setRootChannelActivationPrefix(ch.getActivationPrefix()); + } + } else { + node.setRootChannelActivationPrefix(ch.getActivationPrefix()); + } + } else { + node.setRootChannelActivationPrefix(ch.getActivationPrefix()); + } + } + } + if (node.getChildren() != null) { + for (SysOrgEntity child : node.getChildren()) { + applyRootChannelToNode(child, orgIdToChannelId, channelMap); + } + } + } + + /** + * 为平面列表中的每个组织填充:上级组织=根渠道名称,上级组织编码=渠道编码,根渠道激活码前缀 + * 同时根据mpath填充渠道、一级代理商、二级代理商信息 + */ + /** + * 门店 mpath 约定为:平台.渠道.一级代理.…;与 {@link #fillOrgHierarchyByMpath} 一致时,渠道为第二段,可避免逐条沿 parent 链查库。 + */ + private Long getChannelOrgIdFromStoreMpath(String mpath) { + if (StringUtils.isBlank(mpath)) { + return null; + } + String[] parts = mpath.split("\\."); + if (parts.length < 2 || StringUtils.isBlank(parts[1])) { + return null; + } + try { + return Long.parseLong(parts[1].trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private void fillRootChannelInList(List list) { + if (list == null || list.isEmpty()) return; + Map orgIdToChannelId = new HashMap<>(); + Set idsInMpath = new HashSet<>(); + for (SysOrgEntity org : list) { + if (org != null && org.getId() != null) { + Long cid = getChannelOrgIdFromStoreMpath(org.getMpath()); + if (cid == null) { + cid = getChannelOrgIdForOrg(org.getId()); + } + if (cid != null) orgIdToChannelId.put(org.getId(), cid); + // 提取 mpath 中出现的所有 ID(用于后续查询) + if (StringUtils.isNotBlank(org.getMpath())) { + String[] parts = org.getMpath().split("\\."); + for (String part : parts) { + if (StringUtils.isNotBlank(part)) { + try { + idsInMpath.add(Long.parseLong(part)); + } catch (NumberFormatException e) { + // 忽略非数字部分 + } + } + } + } + } + } + if (orgIdToChannelId.isEmpty()) return; + // 获取所有需要的组织数据(包括渠道及 mpath 中出现的 ID) + Set allNeededIds = new HashSet<>(orgIdToChannelId.values()); + allNeededIds.addAll(idsInMpath); + Map orgMap = this.listByIds(new ArrayList<>(allNeededIds)).stream() + .collect(Collectors.toMap(SysOrgEntity::getId, o -> o, (a, b) -> a)); + for (SysOrgEntity node : list) { + if (node != null) { + applyRootChannelToNode(node, orgIdToChannelId, orgMap); + // 根据mpath填充渠道、一级代理商、二级代理商信息 + fillOrgHierarchyByMpath(node, orgMap); + } + } + } + + /** + * 根据mpath解析并填充渠道、一级代理商、二级代理商信息 + * mpath格式:平台ID.渠道ID.一级代理商ID.二级代理商ID.门店ID. + */ + private void fillOrgHierarchyByMpath(SysOrgEntity node, Map orgMap) { + if (node == null || StringUtils.isBlank(node.getMpath())) { + return; + } + String[] parts = node.getMpath().split("\\."); + if (parts.length < 2) { + return; + } + + // 解析渠道(mpath第二个元素,索引1) + if (parts.length >= 2 && StringUtils.isNotBlank(parts[1])) { + try { + Long channelId = Long.parseLong(parts[1]); + SysOrgEntity channel = orgMap.get(channelId); + if (channel != null && ORG_TYPE_CHANNEL.equals(channel.getType())) { + node.setChannelName(channel.getName()); + } + } catch (NumberFormatException e) { + // 忽略格式错误 + } + } + + // 解析一级代理商(mpath第三个元素,索引2) + if (parts.length >= 3 && StringUtils.isNotBlank(parts[2])) { + try { + Long primaryAgentId = Long.parseLong(parts[2]); + SysOrgEntity primaryAgent = orgMap.get(primaryAgentId); + if (primaryAgent != null && ORG_TYPE_SERVICE_PROVIDER.equals(primaryAgent.getType())) { + node.setPrimaryAgentName(primaryAgent.getName()); + } + } catch (NumberFormatException e) { + // 忽略格式错误 + } + } + + // 解析二级代理商(mpath第四个元素,索引3) + if (parts.length >= 4 && StringUtils.isNotBlank(parts[3])) { + try { + Long secondaryAgentId = Long.parseLong(parts[3]); + SysOrgEntity secondaryAgent = orgMap.get(secondaryAgentId); + if (secondaryAgent != null && ORG_TYPE_SERVICE_PROVIDER.equals(secondaryAgent.getType())) { + node.setSecondaryAgentName(secondaryAgent.getName()); + } + } catch (NumberFormatException e) { + // 忽略格式错误 + } + } + } + + @Override + public Long getChannelOrgIdForOrg(Long orgId) { + if (orgId == null) return null; + Long currentId = orgId; + while (currentId != null) { + SysOrgEntity org = this.getById(currentId); + if (org == null) return null; + if (ORG_TYPE_CHANNEL.equals(org.getType())) return currentId; + currentId = org.getParentId(); + } + return null; + } + + @Override + public Long resolveNearestHoldInventoryAncestorOrgId(Long orgId) { + if (orgId == null) { + return null; + } + SysOrgEntity org = this.getById(orgId); + if (org == null || !DELETE_FLAG_UNDEL.equals(org.getDeleteFlag())) { + return null; + } + Long currentId = org.getParentId(); + while (currentId != null) { + SysOrgEntity node = this.getById(currentId); + if (node == null || !DELETE_FLAG_UNDEL.equals(node.getDeleteFlag())) { + break; + } + if (ORG_TYPE_PLATFORM.equals(node.getType())) { + return node.getId(); + } + boolean canHold = Boolean.TRUE.equals(node.getCanHoldInventory()) || ORG_TYPE_CHANNEL.equals(node.getType()); + if (canHold) { + return node.getId(); + } + currentId = node.getParentId(); + } + return null; + } + + /** {@code descendantMpath} 是否为 {@code ancestorMpath} 的严格下级(多一级或多级,以句点分段) */ + private static boolean isStrictMpathDescendant(String ancestorMpath, String descendantMpath) { + if (StringUtils.isBlank(ancestorMpath) || StringUtils.isBlank(descendantMpath)) { + return false; + } + return descendantMpath.startsWith(ancestorMpath + "."); + } + + @Override + public Long resolvePresetInventorySourceOrgId(Long storeOrgId, Long operatorId) { + if (storeOrgId == null) { + return null; + } + SysOrgEntity store = this.getById(storeOrgId); + if (store == null || StringUtils.isBlank(store.getMpath())) { + return null; + } + SysUserEntity user = operatorId == null ? null : sysUserService.getById(operatorId); + SysOrgEntity userOrg = (user != null && user.getOrgId() != null) ? this.getById(user.getOrgId()) : null; + boolean superAdmin = user != null && Constant.isSuperAdmin(user.getOrgId()); + + String userMpath = (userOrg != null && StringUtils.isNotBlank(userOrg.getMpath())) ? userOrg.getMpath() : null; + boolean limitInventoryToSelfOrAncestor = userOrg != null + && (ORG_TYPE_CHANNEL.equals(userOrg.getType()) || ORG_TYPE_SERVICE_PROVIDER.equals(userOrg.getType())); + String userMpathForDepth = limitInventoryToSelfOrAncestor ? userMpath : null; + + Long currentId = store.getParentId(); + while (currentId != null) { + SysOrgEntity node = this.getById(currentId); + if (node == null) { + break; + } + if (!superAdmin) { + if (StringUtils.isBlank(userMpath) || StringUtils.isBlank(node.getMpath())) { + return null; + } + if (!node.getMpath().startsWith(userMpath)) { + break; + } + if (userMpathForDepth != null && isStrictMpathDescendant(userMpathForDepth, node.getMpath())) { + currentId = node.getParentId(); + continue; + } + } + boolean canHold = Boolean.TRUE.equals(node.getCanHoldInventory()) || ORG_TYPE_CHANNEL.equals(node.getType()); + if (canHold) { + return node.getId(); + } + currentId = node.getParentId(); + } + return null; + } + + @Override + public Long resolveActivationInventorySourceOrgId(Long storeOrgId, Long operatorId, String cardType, int requiredCount) { + if (storeOrgId == null) { + throw new ASException("门店组织不能为空"); + } + if (requiredCount <= 0) { + throw new ASException("分配数量必须大于0"); + } + String cardTypeUse = StringUtils.isNotBlank(cardType) ? cardType.trim() : "YEAR"; + SysOrgEntity store = this.getById(storeOrgId); + if (store == null || StringUtils.isBlank(store.getMpath())) { + throw new ASException("门店组织不存在或路径无效"); + } + SysUserEntity user = operatorId == null ? null : sysUserService.getById(operatorId); + SysOrgEntity userOrg = (user != null && user.getOrgId() != null) ? this.getById(user.getOrgId()) : null; + String userMpath = (userOrg != null && StringUtils.isNotBlank(userOrg.getMpath())) ? userOrg.getMpath() : null; + boolean superAdmin = user != null && Constant.isSuperAdmin(user.getOrgId()); + + Long currentId = store.getParentId(); + while (currentId != null) { + SysOrgEntity node = this.getById(currentId); + if (node == null) { + break; + } + if (!superAdmin) { + if (StringUtils.isBlank(userMpath) || StringUtils.isBlank(node.getMpath())) { + throw new ASException("无法解析库存来源:操作人组织信息无效"); + } + if (!node.getMpath().startsWith(userMpath)) { + break; + } + } + boolean canHold = Boolean.TRUE.equals(node.getCanHoldInventory()) || ORG_TYPE_CHANNEL.equals(node.getType()); + if (canHold) { + long available = countAllocatableCardsAtOrgForActivation(node.getId(), cardTypeUse); + if (available >= requiredCount) { + return node.getId(); + } + String nodeLabel = StringUtils.isNotBlank(node.getName()) ? node.getName().trim() : String.valueOf(node.getId()); + throw new ASException("本账号可见范围内,最近的持仓组织「" + nodeLabel + "」可分配库存不足,需要" + + requiredCount + "张,可用" + available + "张"); + } + currentId = node.getParentId(); + } + throw new ASException("本账号可见范围内,未找到可持仓组织"); + } + + /** 与权益激活码锁定逻辑一致:在库、未使用、未过期且已生效 */ + private long countAllocatableCardsAtOrgForActivation(Long orgId, String cardType) { + if (orgId == null) { + return 0; + } + return cardDao.selectCount(new LambdaQueryWrapper() + .eq(CardEntity::getOrgId, orgId) + .eq(CardEntity::getInventoryStatus, DeviceConstants.IN_STOCK) + .eq(CardEntity::getUsageStatus, CARD_USAGE_UNUSED) + .eq(CardEntity::getCardType, cardType) + .eq(CardEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .apply("third_expire_at >= NOW() AND third_start_at <= NOW()")); + } + + @Override + public Long getChannelOrgIdFromMpath(String mpath) { + return parseChannelOrgIdFromStoreMpath(mpath); + } + + @Override + public Map mapStoreOrgIdToChannelActivationPrefix(Collection storeOrgIds) { + Map result = new HashMap<>(); + if (storeOrgIds == null || storeOrgIds.isEmpty()) { + return result; + } + Set ids = storeOrgIds.stream().filter(Objects::nonNull).collect(Collectors.toSet()); + if (ids.isEmpty()) { + return result; + } + List stores = this.listByIds(new ArrayList<>(ids)); + if (stores == null) { + return result; + } + Map storeToChannelId = new HashMap<>(); + Set channelIds = new HashSet<>(); + for (SysOrgEntity s : stores) { + if (s == null || s.getId() == null) { + continue; + } + Long chId = parseChannelOrgIdFromStoreMpath(s.getMpath()); + if (chId != null) { + storeToChannelId.put(s.getId(), chId); + channelIds.add(chId); + } + } + if (channelIds.isEmpty()) { + return result; + } + Map channelMap = this.listByIds(new ArrayList<>(channelIds)).stream() + .filter(Objects::nonNull) + .collect(Collectors.toMap(SysOrgEntity::getId, o -> o, (a, b) -> a)); + for (Map.Entry e : storeToChannelId.entrySet()) { + SysOrgEntity ch = channelMap.get(e.getValue()); + if (ch != null && StringUtils.isNotBlank(ch.getActivationPrefix())) { + result.put(e.getKey(), ch.getActivationPrefix()); + } + } + return result; + } + + /** + * 与 SQL {@code SUBSTRING_INDEX(SUBSTRING_INDEX(org.mpath,'.',2),'.',-1)} 一致:取门店 mpath 第二段为渠道 ID。 + */ + private static Long parseChannelOrgIdFromStoreMpath(String mpath) { + if (StringUtils.isBlank(mpath)) { + return null; + } + String[] parts = mpath.split("\\."); + if (parts.length < 2 || StringUtils.isBlank(parts[1])) { + return null; + } + try { + return Long.parseLong(parts[1].trim()); + } catch (NumberFormatException e) { + return null; + } + } + + @Override + public List listOrgsInSameChannel(Long orgId, Long operatorId) { + if (orgId == null || operatorId == null) { + return Collections.emptyList(); + } + // 操作人必须有权访问该组织 + if (!isOrgInScope(orgId, operatorId)) { + return Collections.emptyList(); + } + // 找到该组织所属渠道ID(自身为渠道时返回自身) + Long channelOrgId = getChannelOrgIdForOrg(orgId); + if (channelOrgId == null) { + return Collections.emptyList(); + } + // 在操作人数据范围内的所有组织中,筛选出所属渠道相同的组织(含渠道、代理商、门店),构建树形结构 + List scopedList = listOrgByScope(operatorId); + if (scopedList == null || scopedList.isEmpty()) { + return Collections.emptyList(); + } + List flat = new ArrayList<>(); + for (SysOrgEntity org : scopedList) { + if (org == null || org.getId() == null) { + continue; + } + Long cid = getChannelOrgIdForOrg(org.getId()); + if (channelOrgId.equals(cid)) { + flat.add(org); + } + } + if (flat.isEmpty()) { + return Collections.emptyList(); + } + return buildOrgTreeFromFlatList(flat); + } + + /** + * 将同一子集内的组织按 parentId 挂成树:父不在集合内的节点为根(通常即渠道根) + */ + private List buildOrgTreeFromFlatList(List flat) { + Set idSet = flat.stream().map(SysOrgEntity::getId).filter(Objects::nonNull).collect(Collectors.toSet()); + Map> parentIdToChildren = new HashMap<>(); + for (SysOrgEntity o : flat) { + Long pid = o.getParentId(); + if (pid != null && idSet.contains(pid)) { + parentIdToChildren.computeIfAbsent(pid, k -> new ArrayList<>()).add(o); + } + } + Comparator byOrder = Comparator + .comparing(SysOrgEntity::getOrderNo, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(SysOrgEntity::getId, Comparator.nullsLast(Long::compareTo)); + for (List children : parentIdToChildren.values()) { + children.sort(byOrder); + } + List roots = flat.stream() + .filter(o -> o.getParentId() == null || !idSet.contains(o.getParentId())) + .sorted(byOrder) + .collect(Collectors.toList()); + for (SysOrgEntity root : roots) { + buildTreeChildren(root, parentIdToChildren); + } + return roots; + } + + @Override + public OrgCardStatsVo getCardStatsByOrgId(Long orgId, Long operatorId) { + if (orgId == null) return new OrgCardStatsVo(0, 0, 0); + if (!isOrgInScope(orgId, operatorId)) return null; + SysOrgEntity org = this.getById(orgId); + if (org == null) return new OrgCardStatsVo(0, 0, 0); + List orgIds = getDescendantOrgIds(orgId, org.getMpath()); + if (orgIds == null || orgIds.isEmpty()) return new OrgCardStatsVo(0, 0, 0); + + // 可分配:该组织及子级下 IN_STOCK + UNUSED + long allocatableCount = cardDao.selectCount(new LambdaQueryWrapper() + .in(CardEntity::getOrgId, orgIds) + .eq(CardEntity::getInventoryStatus, "IN_STOCK") + .eq(CardEntity::getUsageStatus, "UNUSED") + .ne(CardEntity::getUsageStatus, "EXPIRED") + .apply("third_expire_at >= NOW()") + .eq(CardEntity::getDeleteFlag, "UNDEL")); + + // 已激活:usage_status=ACTIVE + long activatedCount = cardDao.selectCount(new LambdaQueryWrapper() + .in(CardEntity::getOrgId, orgIds) + .eq(CardEntity::getUsageStatus, "ACTIVE") + .ne(CardEntity::getUsageStatus, "EXPIRED") + .eq(CardEntity::getDeleteFlag, "UNDEL")); + + // 待激活:SOLD 且 (UNUSED+contract_activation_code_id 非空 或 BOUND 或 ACTIVATION_FAILED) + long pendingActivationCount = cardDao.selectCount(new LambdaQueryWrapper() + .in(CardEntity::getOrgId, orgIds) + .eq(CardEntity::getInventoryStatus, "SOLD") + .ne(CardEntity::getUsageStatus, "EXPIRED") + .eq(CardEntity::getDeleteFlag, "UNDEL") + .and(w -> w.and(ww -> ww.eq(CardEntity::getUsageStatus, "UNUSED").isNotNull(CardEntity::getContractActivationCodeId)) + .or().in(CardEntity::getUsageStatus, Arrays.asList("BOUND", "ACTIVATION_FAILED")))); + + return new OrgCardStatsVo(allocatableCount, activatedCount, pendingActivationCount); + } + + @Override + public OrgCardStatsByTypeVo getCardStatsByOrgIdWithType(Long orgId, Long operatorId) { + if (orgId == null) { + OrgCardStatsByTypeVo.TotalStats total = new OrgCardStatsByTypeVo.TotalStats(0, 0, 0); + return new OrgCardStatsByTypeVo(total, new ArrayList<>()); + } + + boolean isPlatformLevel = (orgId == 1); + // 已激活/待激活:本组织 + 子级;可分配:仅本组织 card.org_id + List descendantOrgIds = null; + if (!isPlatformLevel) { + if (!isOrgInScope(orgId, operatorId)) return null; + SysOrgEntity org = this.getById(orgId); + if (org == null) { + OrgCardStatsByTypeVo.TotalStats total = new OrgCardStatsByTypeVo.TotalStats(0, 0, 0); + return new OrgCardStatsByTypeVo(total, new ArrayList<>()); + } + descendantOrgIds = getDescendantOrgIds(orgId, org.getMpath()); + if (descendantOrgIds == null || descendantOrgIds.isEmpty()) { + descendantOrgIds = Collections.singletonList(orgId); + } + } + + // 定义卡类型列表 + List cardTypes = Arrays.asList("YEAR", "QUARTER", "MONTH", "WEEK", "OTHER"); + Map cardTypeNameMap = new HashMap<>(); + cardTypeNameMap.put("YEAR", "年卡"); + cardTypeNameMap.put("QUARTER", "季卡"); + cardTypeNameMap.put("MONTH", "月卡"); + cardTypeNameMap.put("WEEK", "周卡"); + cardTypeNameMap.put("OTHER", "天卡"); + + List byTypeList = new ArrayList<>(); + long totalAllocatable = 0; + long totalActivated = 0; + long totalPending = 0; + + for (String cardType : cardTypes) { + // 可分配:仅本组织 org_id=orgId + LambdaQueryWrapper allocatableWrapper = new LambdaQueryWrapper() + .eq(CardEntity::getCardType, cardType) + .eq(CardEntity::getInventoryStatus, "IN_STOCK") + .eq(CardEntity::getUsageStatus, "UNUSED") + .apply("third_expire_at >= NOW()") + .eq(CardEntity::getDeleteFlag, "UNDEL"); + if (!isPlatformLevel) { + allocatableWrapper.eq(CardEntity::getOrgId, orgId); + } + long allocatableCount = cardDao.selectCount(allocatableWrapper); + + // 已激活:本组织及子级 + LambdaQueryWrapper activatedWrapper = new LambdaQueryWrapper() + .eq(CardEntity::getCardType, cardType) + .in(CardEntity::getUsageStatus, "ACTIVE","WAITING_MIGRATION","EXPIRED","ACTIVATING","ACTIVATION_FAILED") + .eq(CardEntity::getDeleteFlag, "UNDEL"); + if (!isPlatformLevel) { + activatedWrapper.in(CardEntity::getOrgId, descendantOrgIds); + } + long activatedCount = cardDao.selectCount(activatedWrapper); + + // 待激活:本组织及子级 + LambdaQueryWrapper pendingWrapper = new LambdaQueryWrapper() + .eq(CardEntity::getCardType, cardType) + .eq(CardEntity::getInventoryStatus, "SOLD") + .in(CardEntity::getUsageStatus, "BOUND","UNUSED") + .eq(CardEntity::getDeleteFlag, "UNDEL"); + if (!isPlatformLevel) { + pendingWrapper.in(CardEntity::getOrgId, descendantOrgIds); + } + long pendingActivationCount = cardDao.selectCount(pendingWrapper); + + CardStatsByTypeVo stats = new CardStatsByTypeVo( + cardType, + cardTypeNameMap.getOrDefault(cardType, cardType), + allocatableCount, + activatedCount, + pendingActivationCount + ); + byTypeList.add(stats); + + totalAllocatable += allocatableCount; + totalActivated += activatedCount; + totalPending += pendingActivationCount; + } + + OrgCardStatsByTypeVo.TotalStats total = new OrgCardStatsByTypeVo.TotalStats(totalAllocatable, totalActivated, totalPending); + return new OrgCardStatsByTypeVo(total, byTypeList); + } + + @Override + public OrgCardStatsByTypeVo getAllCardStatsWithType() { + // 定义卡类型列表 + List cardTypes = Arrays.asList("YEAR", "QUARTER", "MONTH", "WEEK", "OTHER"); + Map cardTypeNameMap = new HashMap<>(); + cardTypeNameMap.put("YEAR", "年卡"); + cardTypeNameMap.put("QUARTER", "季卡"); + cardTypeNameMap.put("MONTH", "月卡"); + cardTypeNameMap.put("WEEK", "周卡"); + cardTypeNameMap.put("OTHER", "天卡"); + + List byTypeList = new ArrayList<>(); + long totalAllocatable = 0; + long totalActivated = 0; + long totalPending = 0; + + for (String cardType : cardTypes) { + // 可分配:IN_STOCK + UNUSED + long allocatableCount = cardDao.selectCount(new LambdaQueryWrapper() + .eq(CardEntity::getCardType, cardType) + .eq(CardEntity::getInventoryStatus, "IN_STOCK") + .eq(CardEntity::getUsageStatus, "UNUSED") + .apply("third_expire_at >= NOW() AND org_id is null") + .eq(CardEntity::getDeleteFlag, "UNDEL")); + + // 已激活:usage_status=ACTIVE + long activatedCount = cardDao.selectCount(new LambdaQueryWrapper() + .eq(CardEntity::getCardType, cardType) + .in(CardEntity::getUsageStatus, "ACTIVE","WAITING_MIGRATION","EXPIRED","ACTIVATING","ACTIVATION_FAILED") + .eq(CardEntity::getDeleteFlag, "UNDEL")); + + // 待激活:SOLD 且 (UNUSED+contract_activation_code_id 非空 或 BOUND 或 ACTIVATION_FAILED) + long pendingActivationCount = cardDao.selectCount(new LambdaQueryWrapper() + .eq(CardEntity::getCardType, cardType) + .eq(CardEntity::getInventoryStatus, "SOLD") + .in(CardEntity::getUsageStatus,"UNUSED","BOUND") + .apply("third_expire_at >= NOW()") + .eq(CardEntity::getDeleteFlag, "UNDEL")); + + CardStatsByTypeVo stats = new CardStatsByTypeVo( + cardType, + cardTypeNameMap.getOrDefault(cardType, cardType), + allocatableCount, + activatedCount, + pendingActivationCount + ); + byTypeList.add(stats); + + totalAllocatable += allocatableCount; + totalActivated += activatedCount; + totalPending += pendingActivationCount; + } + + OrgCardStatsByTypeVo.TotalStats total = new OrgCardStatsByTypeVo.TotalStats(totalAllocatable, totalActivated, totalPending); + return new OrgCardStatsByTypeVo(total, byTypeList); + } + + @Override + public List getCardDetailStatsYearMonthDay(Long orgId, Long operatorId) { + Long effectiveOrgId = orgId != null ? orgId : resolveCardOverviewOrgId(null, operatorId); + if (effectiveOrgId == null) { + return emptyCardDetailStatsYearMonthDay(); + } + boolean isPlatformLevel = (effectiveOrgId == 1L); + final List orgIds; + if (isPlatformLevel) { + orgIds = null; + } else { + if (!isOrgInScope(effectiveOrgId, operatorId)) { + return null; + } + SysOrgEntity org = this.getById(effectiveOrgId); + if (org == null) { + return emptyCardDetailStatsYearMonthDay(); + } + orgIds = getDescendantOrgIds(effectiveOrgId, org.getMpath()); + if (orgIds == null || orgIds.isEmpty()) { + return emptyCardDetailStatsYearMonthDay(); + } + } + + List cardTypes = Arrays.asList("YEAR", "MONTH", "OTHER"); + Map cardTypeNameMap = new LinkedHashMap<>(); + cardTypeNameMap.put("YEAR", "年卡"); + cardTypeNameMap.put("MONTH", "月卡"); + cardTypeNameMap.put("OTHER", "天卡"); + + List activatedTerminalStatuses = Arrays.asList( + DeviceConstants.USAGE_ACTIVE, + DeviceConstants.USAGE_EXPIRED, + DeviceConstants.USAGE_WAITING_MIGRATION); + + List rows = new ArrayList<>(); + for (String cardType : cardTypes) { + long totalPurchased = cardDao.selectCount(new LambdaQueryWrapper() + .eq(CardEntity::getCardType, cardType) + .eq(CardEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .and(!isPlatformLevel, w -> w.in(CardEntity::getOrgId, orgIds))); + + long presetBound = cardDao.selectCount(new LambdaQueryWrapper() + .eq(CardEntity::getCardType, cardType) + .eq(CardEntity::getAllocationScene, DeviceConstants.PRE_IMPORT) + .eq(CardEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .and(!isPlatformLevel, w -> w.in(CardEntity::getOrgId, orgIds))); + + long presetBoundActivated = cardDao.selectCount(new LambdaQueryWrapper() + .eq(CardEntity::getCardType, cardType) + .eq(CardEntity::getAllocationScene, DeviceConstants.PRE_IMPORT) + .in(CardEntity::getUsageStatus, activatedTerminalStatuses) + .eq(CardEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .and(!isPlatformLevel, w -> w.in(CardEntity::getOrgId, orgIds))); + + long activationCodeAllocated = cardDao.selectCount(new LambdaQueryWrapper() + .eq(CardEntity::getCardType, cardType) + .eq(CardEntity::getAllocationScene, DeviceConstants.ACTIVE_CODE) + .eq(CardEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .and(!isPlatformLevel, w -> w.in(CardEntity::getOrgId, orgIds))); + + long activationCodeActivated = cardDao.selectCount(new LambdaQueryWrapper() + .eq(CardEntity::getCardType, cardType) + .eq(CardEntity::getAllocationScene, DeviceConstants.ACTIVE_CODE) + .in(CardEntity::getUsageStatus, activatedTerminalStatuses) + .eq(CardEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .and(!isPlatformLevel, w -> w.in(CardEntity::getOrgId, orgIds))); + + long activationAbnormal = cardDao.selectCount(new LambdaQueryWrapper() + .eq(CardEntity::getCardType, cardType) + .eq(CardEntity::getUsageStatus, DeviceConstants.USAGE_ACTIVATION_FAILED) + .eq(CardEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .and(!isPlatformLevel, w -> w.in(CardEntity::getOrgId, orgIds))); + + long waitingMigration = cardDao.selectCount(new LambdaQueryWrapper() + .eq(CardEntity::getCardType, cardType) + .eq(CardEntity::getUsageStatus, DeviceConstants.USAGE_WAITING_MIGRATION) + .eq(CardEntity::getDeleteFlag, DELETE_FLAG_UNDEL) + .and(!isPlatformLevel, w -> w.in(CardEntity::getOrgId, orgIds))); + + LambdaQueryWrapper availableWrapper = new LambdaQueryWrapper() + .eq(CardEntity::getCardType, cardType) + .eq(CardEntity::getInventoryStatus, DeviceConstants.IN_STOCK) + .eq(CardEntity::getUsageStatus, DeviceConstants.UNUSED) + .apply("third_expire_at >= NOW()") + .eq(CardEntity::getDeleteFlag, DELETE_FLAG_UNDEL); + if (!isPlatformLevel) { + availableWrapper.in(CardEntity::getOrgId, orgIds); + } + long available = cardDao.selectCount(availableWrapper); + + rows.add(new CardTypeDetailStatsVo( + cardType, + cardTypeNameMap.get(cardType), + totalPurchased, + presetBound, + presetBoundActivated, + activationCodeAllocated, + activationCodeActivated, + activationAbnormal, + waitingMigration, + available)); + } + return rows; + } + + /** 年/月/天卡明细统计:各指标为 0 的占位列表 */ + private List emptyCardDetailStatsYearMonthDay() { + Map cardTypeNameMap = new LinkedHashMap<>(); + cardTypeNameMap.put("YEAR", "年卡"); + cardTypeNameMap.put("MONTH", "月卡"); + cardTypeNameMap.put("OTHER", "天卡"); + List rows = new ArrayList<>(); + for (String cardType : Arrays.asList("YEAR", "MONTH", "OTHER")) { + rows.add(new CardTypeDetailStatsVo(cardType, cardTypeNameMap.get(cardType), 0, 0, 0, 0, 0, 0, 0, 0)); + } + return rows; + } + + @Override + public List getCardAllocatableOverviewByType(Long orgId, Long storeOrgId, Long operatorId) { + Long effectiveOrgId = resolveCardOverviewOrgId(orgId, operatorId); + if (effectiveOrgId == null) { + return emptyAllocatableByType(); + } + if (!isOrgInScope(effectiveOrgId, operatorId)) { + return null; + } + boolean isPlatformLevel = (effectiveOrgId == 1L); + List orgIds = null; + if (!isPlatformLevel) { + SysOrgEntity org = this.getById(effectiveOrgId); + if (org == null) { + return emptyAllocatableByType(); + } + orgIds = getDescendantOrgIds(effectiveOrgId, org.getMpath()); + if (orgIds == null || orgIds.isEmpty()) { + return emptyAllocatableByType(); + } + } + Long cardOrgFilter = null; + if (storeOrgId != null) { + if (!isOrgInScope(storeOrgId, operatorId)) { + return null; + } + if (!isPlatformLevel) { + if (orgIds == null || !orgIds.contains(storeOrgId)) { + return null; + } + } + Long channelOrgIdForStore = getChannelOrgIdForOrg(storeOrgId); + if (channelOrgIdForStore == null) { + return emptyAllocatableByType(); + } + // 门店视角:剩余可用量为所属渠道持仓(非门店 org_id 上的卡) + cardOrgFilter = channelOrgIdForStore; + } + List cardTypes = Arrays.asList("YEAR", "QUARTER", "MONTH", "WEEK", "OTHER"); + Map cardTypeNameMap = new HashMap<>(); + cardTypeNameMap.put("YEAR", "年卡"); + cardTypeNameMap.put("QUARTER", "季卡"); + cardTypeNameMap.put("MONTH", "月卡"); + cardTypeNameMap.put("WEEK", "周卡"); + cardTypeNameMap.put("OTHER", "天卡"); + List list = new ArrayList<>(); + for (String cardType : cardTypes) { + LambdaQueryWrapper allocatableWrapper = new LambdaQueryWrapper() + .eq(CardEntity::getCardType, cardType) + .eq(CardEntity::getInventoryStatus, "IN_STOCK") + .eq(CardEntity::getUsageStatus, "UNUSED") + .apply("third_expire_at >= NOW()") + .eq(CardEntity::getDeleteFlag, DELETE_FLAG_UNDEL); + if (cardOrgFilter != null) { + allocatableWrapper.eq(CardEntity::getOrgId, cardOrgFilter); + } else if (!isPlatformLevel) { + allocatableWrapper.in(CardEntity::getOrgId, orgIds); + } + long allocatableCount = cardDao.selectCount(allocatableWrapper); + list.add(new OrgCardTypeAllocatableVo(cardType, cardTypeNameMap.getOrDefault(cardType, cardType), allocatableCount)); + } + return list; + } + + /** card-overview:未传 orgId 时解析为全平台(1)或当前用户所属组织 */ + private Long resolveCardOverviewOrgId(Long requestOrgId, Long operatorId) { + if (requestOrgId != null) { + return requestOrgId; + } + SysUserEntity user = sysUserService.getById(operatorId); + if (user == null) { + return null; + } + if (Constant.isSuperAdmin(user.getOrgId())) { + return 1L; + } + Long uid = user.getOrgId(); + if (uid == null) { + return null; + } + SysOrgEntity uo = this.getById(uid); + if (uo != null && ORG_TYPE_PLATFORM.equals(uo.getType())) { + return 1L; + } + return uid; + } + + private List emptyAllocatableByType() { + List cardTypes = Arrays.asList("YEAR", "QUARTER", "MONTH", "WEEK", "OTHER"); + Map cardTypeNameMap = new HashMap<>(); + cardTypeNameMap.put("YEAR", "年卡"); + cardTypeNameMap.put("QUARTER", "季卡"); + cardTypeNameMap.put("MONTH", "月卡"); + cardTypeNameMap.put("WEEK", "周卡"); + cardTypeNameMap.put("OTHER", "天卡"); + List list = new ArrayList<>(); + for (String cardType : cardTypes) { + list.add(new OrgCardTypeAllocatableVo(cardType, cardTypeNameMap.getOrDefault(cardType, cardType), 0L)); + } + return list; + } + + /** 该组织及子级组织 ID 列表(含自身,未删除) */ + private List getDescendantOrgIds(Long orgId, String mpath) { + if (orgId == null) return Collections.emptyList(); + if (StringUtils.isBlank(mpath)) { + SysOrgEntity o = this.getById(orgId); + return o == null ? Collections.emptyList() : Collections.singletonList(orgId); + } + QueryWrapper q = new QueryWrapper() + .select("id") + .eq("delete_flag", DELETE_FLAG_UNDEL) + .and(w -> w.eq("id", orgId).or().apply("mpath LIKE CONCAT({0}, '%')", mpath)); + List list = this.list(q); + return list == null ? Collections.emptyList() : list.stream().map(SysOrgEntity::getId).distinct().collect(Collectors.toList()); + } + + /** + * 在内存中递归挂载子节点(parentIdToChildren 的 key 为 -1 表示 parent_id 为 null 的根) + */ + private void buildTreeChildren(SysOrgEntity node, Map> parentIdToChildren) { + List children = parentIdToChildren.getOrDefault(node.getId(), Collections.emptyList()); + children.forEach(child -> buildTreeChildren(child, parentIdToChildren)); + node.setChildren(children); + } + + /** + * 渠道创建时:系统自动生成渠道专属前缀与组织编码(不从前端读取),创建后不可变 + * 规则:渠道专属前缀 2 位大写(允许字符集、黑名单)、全局唯一;组织编码 = 6 位随机字符(不含渠道前缀,全局唯一) + * @return 填充了 code、activationPrefix 后的 org + */ + private SysOrgEntity validateAndFillChannelCode(SysOrgEntity org) { + String prefix = null; + int maxRetry = 30; + for (int i = 0; i < maxRetry; i++) { + prefix = OrgCodeConstants.generateChannelPrefix(); + if (prefix == null || prefix.length() != OrgCodeConstants.CHANNEL_PREFIX_LENGTH) { + continue; + } + long existPrefix = this.lambdaQuery() + .eq(SysOrgEntity::getActivationPrefix, prefix) + .count(); + if (existPrefix == 0) { + break; + } + prefix = null; + } + if (prefix == null) { + throw new ASException("渠道专属前缀生成冲突,请稍后重试"); + } + org.setActivationPrefix(prefix); + + // 生成渠道编码(code)并确保全局唯一 + String code = null; + int codeMaxRetry = 30; + for (int i = 0; i < codeMaxRetry; i++) { + code = OrgCodeConstants.generateChannelOrgCode(); + if (code == null || code.length() != OrgCodeConstants.ORG_CODE_RANDOM_SUFFIX_LENGTH) { + continue; + } + long existCode = this.lambdaQuery() + .eq(SysOrgEntity::getCode, code) + .count(); + if (existCode == 0) { + break; + } + code = null; + } + if (code == null) { + throw new ASException("渠道编码生成冲突,请稍后重试"); + } + org.setCode(code); + return org; + } + + /** + * 沿父级向上查找所属渠道,返回其 activation_prefix;找不到返回 null + */ + private String getChannelActivationPrefixForOrg(Long parentId) { + Long currentId = parentId; + while (currentId != null) { + SysOrgEntity parent = this.getById(currentId); + if (parent == null) { + return null; + } + if (ORG_TYPE_CHANNEL.equals(parent.getType()) && StringUtils.isNotBlank(parent.getActivationPrefix())) { + return parent.getActivationPrefix(); + } + currentId = parent.getParentId(); + } + return null; + } + + /** + * 代理商专有展示/联系类字段(拓展人员、联系电话、服务合伙人、微信二维码)仅 SERVICE_PROVIDER 使用; + * 其他类型置 null;代理商侧字符串空白 trim 后转 null。 + */ + private void normalizeServiceProviderExclusiveFields(SysOrgEntity org) { + if (org == null) { + return; + } + if (!ORG_TYPE_SERVICE_PROVIDER.equals(org.getType())) { + org.setDevelopmentStaff(null); + org.setContactPhone(null); + org.setServicePartner(null); + org.setWechatQrImageUrl(null); + return; + } + org.setDevelopmentStaff(trimToNull(org.getDevelopmentStaff())); + org.setContactPhone(trimToNull(org.getContactPhone())); + org.setServicePartner(trimToNull(org.getServicePartner())); + org.setWechatQrImageUrl(trimToNull(org.getWechatQrImageUrl())); + } + + private static String trimToNull(String s) { + if (s == null) { + return null; + } + String t = s.trim(); + return StringUtils.isNotBlank(t) ? t : null; + } + + /** + * 组织类型与父级关系校验:平台只能为根;渠道只能属于平台;代理商只能属于渠道或代理商;门店只能属于渠道或代理商(不允许平台)。 + */ + private void validateOrgParentType(SysOrgEntity org) { + String type = org.getType(); + Long parentId = org.getParentId(); + if (StringUtils.isBlank(type)) { + return; + } + if (ORG_TYPE_PLATFORM.equals(type)) { + if (parentId != null) { + throw new ASException("平台组织不能有上级,只能作为根组织"); + } + return; + } + if (parentId == null) { + throw new ASException("渠道、代理商、门店必须选择上级组织"); + } + SysOrgEntity parent = this.getById(parentId); + if (parent == null) { + throw new ASException("上级组织不存在"); + } + String parentType = parent.getType(); + if (ORG_TYPE_CHANNEL.equals(type)) { + if (!ORG_TYPE_PLATFORM.equals(parentType)) { + throw new ASException("渠道只能属于平台"); + } + return; + } + if (ORG_TYPE_SERVICE_PROVIDER.equals(type)) { + if (!ORG_TYPE_CHANNEL.equals(parentType) && !ORG_TYPE_SERVICE_PROVIDER.equals(parentType)) { + throw new ASException("代理商只能属于渠道或代理商"); + } + return; + } + if (ORG_TYPE_STORE.equals(type)) { + if (ORG_TYPE_PLATFORM.equals(parentType)) { + throw new ASException("门店的上级不允许为平台,请选择渠道或代理商"); + } + if (!ORG_TYPE_CHANNEL.equals(parentType) && !ORG_TYPE_SERVICE_PROVIDER.equals(parentType)) { + throw new ASException("门店只能属于渠道或代理商"); + } + } + } + + /** + * 组织树是否按「全量根」展开:超管 / orgId 为空时为 true,树形为「平台→渠道→代理商」(无平台数据时退回以渠道为根)。 + * 绑定在代理商(SERVICE_PROVIDER)组织上的账号始终为 false,仅展示该代理商及其下级, + * 避免 userId 落在超管 ID 列表或同时具备超管身份时仍展开全渠道/全平台树。 + * @param bindOrgCached 已加载的绑定组织,可传 null 则按 userOrgId 再查 + */ + private boolean useGlobalOrgTreeRoots(Long operatorId, Long userOrgId, SysOrgEntity bindOrgCached) { + if (operatorId == null) { + return false; + } + SysOrgEntity bindOrg = bindOrgCached; + if (bindOrg == null && userOrgId != null) { + bindOrg = this.getById(userOrgId); + } + if (bindOrg != null && ORG_TYPE_SERVICE_PROVIDER.equals(bindOrg.getType())) { + return false; + } + return Constant.isSuperAdmin(userOrgId); + } + + /** + * 数据隔离:仅允许查看本人所属组织及其所有子级。 + * 超级管理员或 orgId 为空(视为超级管理员)时不过滤,返回所有数据。 + * @return 传入的 wrapper,便于链式调用 + */ + private QueryWrapper applyOrgScope(QueryWrapper wrapper, Long operatorId) { + if (operatorId == null) { + wrapper.eq("id", -1); + return wrapper; + } + SysUserEntity user = sysUserService.getById(operatorId); + Long orgId = user == null ? null : user.getOrgId(); + // 超级管理员或 orgId 为空:返回所有数据,不加组织条件 + if (Constant.isSuperAdmin(orgId)) { + return wrapper; + } + SysOrgEntity org = this.getById(orgId); + if (org == null || StringUtils.isBlank(org.getMpath())) { + wrapper.eq("id", -1); + return wrapper; + } + String mpath = org.getMpath(); + wrapper.and(w -> w.eq("id", orgId).or().apply("mpath LIKE CONCAT({0}, '%')", mpath)); + return wrapper; + } + + /** + * 构建 mpath:根节点为 "id.",子节点为 "父mpath + id." + */ + private void buildMpath(SysOrgEntity org) { + if (org.getId() == null) { + return; + } + if (org.getParentId() == null) { + org.setMpath(org.getId() + "."); + return; + } + SysOrgEntity parent = this.getById(org.getParentId()); + if (parent != null && StringUtils.isNotBlank(parent.getMpath())) { + org.setMpath(parent.getMpath() + org.getId() + "."); + } else { + org.setMpath(org.getId() + "."); + } + } + + /** + * 门店需同步写入/更新 store_profile 扩展表 + * + * @param orgId 门店组织ID + * @param profile 前端传入的扩展信息,可为 null(则用默认值建一条) + * @param operatorId 操作人(未写入 store_profile 表,仅预留) + * @param isCreate true=新增门店,false=更新门店 + */ + private void saveOrUpdateStoreProfile(Long orgId, StoreProfileEntity profile, Long operatorId, boolean isCreate) { + Date now = new Date(); + StoreProfileEntity entity = storeProfileService.getById(orgId); + if (entity == null) { + entity = new StoreProfileEntity(); + entity.setOrgId(orgId); + entity.setCountry("中国"); + entity.setStatus("ACTIVE"); + entity.setDeleteFlag(DELETE_FLAG_UNDEL); + entity.setCreatedAt(now); + } + if (profile != null) { + if (profile.getStoreName() != null) { + entity.setStoreName(profile.getStoreName()); + } + entity.setLegalPerson(profile.getLegalPerson()); + entity.setLicenseNo(profile.getLicenseNo()); + entity.setLicenseImageUrl(profile.getLicenseImageUrl()); + if (profile.getCountry() != null) { + entity.setCountry(profile.getCountry()); + } + entity.setRegion(profile.getRegion()); + entity.setAddress(profile.getAddress()); + entity.setPhone(profile.getPhone()); + entity.setRemark(profile.getRemark()); + if (profile.getStatus() != null) { + entity.setStatus(profile.getStatus()); + } + } + entity.setUpdatedAt(now); + storeProfileService.saveOrUpdate(entity); + } + + /** + * 为渠道/代理商/门店创建登录用户:用户名和手机号为传入的电话,密码由 SysUserService 加密,并赋予 role_name 对应的角色 + * 添加时校验登录账号(username)是否已存在 + */ + private void createOrgLoginUser(SysOrgEntity org, Long operatorId) { + String mobile = org.getLoginMobile().trim(); + // 校验账号是否已存在 + SysUserEntity existUser = sysUserService.queryByUserName(mobile); + if (existUser != null) { + throw new ASException("登录账号已存在:" + mobile); + } + Long roleIdByType = requireRoleIdForOrgType(org.getType()); + SysUserEntity user = new SysUserEntity(); + user.setUsername(mobile); + user.setMobile(mobile); + user.setPassword(org.getLoginPassword()); + user.setEmail(mobile + "@org.local"); + user.setStatus(1); + user.setOrgId(org.getId()); + String typeLabel = ORG_TYPE_CHANNEL.equals(org.getType()) ? "渠道" : ORG_TYPE_SERVICE_PROVIDER.equals(org.getType()) ? "代理商" : "门店"; + user.setRemark(typeLabel + "[" + org.getName() + "]登录账号"); + user.setRoleIdList(Collections.singletonList(roleIdByType)); + sysUserService.saveUser(user, operatorId); + } + + /** + * 根据组织类型解析 sys_role.role_name(渠道/代理商/门店),不存在则抛错,避免登录账号无角色 + */ + private Long requireRoleIdForOrgType(String orgType) { + String roleName = OrgTypeRoleConstants.roleNameForOrgType(orgType); + if (roleName == null) { + throw new ASException("当前组织类型不支持创建登录账号"); + } + SysRoleEntity role = sysRoleService.lambdaQuery() + .eq(SysRoleEntity::getRoleName, roleName) + .last("LIMIT 1") + .one(); + if (role == null) { + throw new ASException("请先在「角色管理」中创建角色,名称须为「" + roleName + "」"); + } + return role.getId(); + } + + /** + * 修改组织时设置或更新登录账号:若该组织下已有用户则更新,否则新建(新建时校验账号是否已存在) + */ + private void saveOrUpdateOrgLoginUser(SysOrgEntity org, Long operatorId) { + String loginMobile = org.getLoginMobile() != null ? org.getLoginMobile().trim() : ""; + String loginPassword = org.getLoginPassword(); + List existing = sysUserService.lambdaQuery() + .eq(SysUserEntity::getOrgId, org.getId()) + .list(); + if (existing != null && !existing.isEmpty()) { + SysUserEntity user = existing.get(0); + if (StringUtils.isNotBlank(loginMobile) && !loginMobile.equals(user.getUsername())) { + SysUserEntity existUser = sysUserService.queryByUserName(loginMobile); + if (existUser != null) { + throw new ASException("登录账号已存在:" + loginMobile); + } + user.setUsername(loginMobile); + user.setMobile(loginMobile); + user.setEmail(loginMobile + "@org.local"); + } + if (StringUtils.isNotBlank(loginPassword)) { + user.setPassword(loginPassword); + } + Long roleIdByType = requireRoleIdForOrgType(org.getType()); + user.setRoleIdList(Collections.singletonList(roleIdByType)); + sysUserService.update(user, operatorId); + return; + } + if (StringUtils.isNotBlank(loginMobile) && StringUtils.isNotBlank(loginPassword)) { + createOrgLoginUser(org, operatorId); + } + } +} diff --git a/src/main/java/io/as/modules/sys/service/impl/SysRoleMenuServiceImpl.java b/src/main/java/io/as/modules/sys/service/impl/SysRoleMenuServiceImpl.java new file mode 100644 index 0000000..83ea6af --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/impl/SysRoleMenuServiceImpl.java @@ -0,0 +1,39 @@ +package io.as.modules.sys.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.modules.sys.dao.SysRoleMenuDao; +import io.as.modules.sys.entity.SysRoleMenuEntity; +import io.as.modules.sys.service.SysRoleMenuService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service("sysRoleMenuService") +public class SysRoleMenuServiceImpl extends ServiceImpl implements SysRoleMenuService { + + @Override + @Transactional(rollbackFor = Exception.class) + public void saveOrUpdate(Long roleId, List menuIdList) { + deleteBatch(new Long[]{roleId}); + if (menuIdList == null || menuIdList.size() == 0) { + return; + } + for (Long menuId : menuIdList) { + SysRoleMenuEntity sysRoleMenuEntity = new SysRoleMenuEntity(); + sysRoleMenuEntity.setMenuId(menuId); + sysRoleMenuEntity.setRoleId(roleId); + this.save(sysRoleMenuEntity); + } + } + + @Override + public List queryMenuIdList(Long roleId) { + return baseMapper.queryMenuIdList(roleId); + } + + @Override + public int deleteBatch(Long[] roleIds) { + return baseMapper.deleteBatch(roleIds); + } +} diff --git a/src/main/java/io/as/modules/sys/service/impl/SysRoleServiceImpl.java b/src/main/java/io/as/modules/sys/service/impl/SysRoleServiceImpl.java new file mode 100644 index 0000000..eedfee2 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/impl/SysRoleServiceImpl.java @@ -0,0 +1,88 @@ +package io.as.modules.sys.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.exception.ASException; +import io.as.common.utils.Constant; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.sys.dao.SysRoleDao; +import io.as.modules.sys.dao.SysUserDao; +import io.as.modules.sys.entity.SysRoleEntity; +import io.as.modules.sys.entity.SysUserEntity; +import io.as.modules.sys.service.SysRoleMenuService; +import io.as.modules.sys.service.SysRoleService; +import io.as.modules.sys.service.SysUserRoleService; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; + +@Service("sysRoleService") +public class SysRoleServiceImpl extends ServiceImpl implements SysRoleService { + @Autowired + private SysRoleMenuService sysRoleMenuService; + @Autowired + private SysUserDao sysUserDao; + @Autowired + private SysUserRoleService sysUserRoleService; + + @Override + public PageUtils queryPage(Map params) { + String roleName = (String) params.get("roleName"); + IPage page = this.page( + new Query().getPage(params), + new QueryWrapper() + .like(StringUtils.isNotBlank(roleName), "role_name", roleName) + ); + return new PageUtils(page); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void saveRole(SysRoleEntity role, Long operatorId) { + role.setCreatedAt(new Date()); + this.save(role); + checkPrems(role, operatorId); + sysRoleMenuService.saveOrUpdate(role.getId(), role.getMenuIdList()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void update(SysRoleEntity role, Long operatorId) { + role.setUpdatedAt(new Date()); + this.updateById(role); + checkPrems(role, operatorId); + sysRoleMenuService.saveOrUpdate(role.getId(), role.getMenuIdList()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteBatch(Long[] roleIds) { + sysRoleMenuService.deleteBatch(roleIds); + sysUserRoleService.deleteBatch(roleIds); + this.removeByIds(Arrays.asList(roleIds)); + } + + @Override + public List queryRoleIdList() { + return baseMapper.queryRoleIdList(); + } + + private void checkPrems(SysRoleEntity role, Long operatorId) { + SysUserEntity operator = sysUserDao.selectById(operatorId); + if (operator != null && Constant.isSuperAdmin(operator.getOrgId())) { + return; + } + List menuIdList = sysUserDao.queryAllMenuId(operatorId); + if (menuIdList != null && role.getMenuIdList() != null && !menuIdList.containsAll(role.getMenuIdList())) { + throw new ASException("新增角色的权限,已超出你的权限范围"); + } + } +} diff --git a/src/main/java/io/as/modules/sys/service/impl/SysUserRoleServiceImpl.java b/src/main/java/io/as/modules/sys/service/impl/SysUserRoleServiceImpl.java new file mode 100644 index 0000000..5f30d45 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/impl/SysUserRoleServiceImpl.java @@ -0,0 +1,38 @@ +package io.as.modules.sys.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.utils.MapUtils; +import io.as.modules.sys.dao.SysUserRoleDao; +import io.as.modules.sys.entity.SysUserRoleEntity; +import io.as.modules.sys.service.SysUserRoleService; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service("sysUserRoleService") +public class SysUserRoleServiceImpl extends ServiceImpl implements SysUserRoleService { + + @Override + public void saveOrUpdate(Long userId, List roleIdList) { + this.removeByMap(new MapUtils().put("user_id", userId)); + if (roleIdList == null || roleIdList.size() == 0) { + return; + } + for (Long roleId : roleIdList) { + SysUserRoleEntity sysUserRoleEntity = new SysUserRoleEntity(); + sysUserRoleEntity.setUserId(userId); + sysUserRoleEntity.setRoleId(roleId); + this.save(sysUserRoleEntity); + } + } + + @Override + public List queryRoleIdList(Long userId) { + return baseMapper.queryRoleIdList(userId); + } + + @Override + public int deleteBatch(Long[] roleIds) { + return baseMapper.deleteBatch(roleIds); + } +} diff --git a/src/main/java/io/as/modules/sys/service/impl/SysUserServiceImpl.java b/src/main/java/io/as/modules/sys/service/impl/SysUserServiceImpl.java new file mode 100644 index 0000000..f4a6086 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/impl/SysUserServiceImpl.java @@ -0,0 +1,157 @@ +package io.as.modules.sys.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.exception.ASException; +import io.as.common.utils.Constant; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.sys.constants.OrgTypeRoleConstants; +import io.as.modules.sys.dao.SysOrgDao; +import io.as.modules.sys.dao.SysUserDao; +import io.as.modules.sys.entity.SysOrgEntity; +import io.as.modules.sys.entity.SysRoleEntity; +import io.as.modules.sys.entity.SysUserEntity; +import io.as.modules.sys.service.SysRoleService; +import io.as.modules.sys.vo.SysUserListVo; +import io.as.modules.sys.service.SysUserRoleService; +import io.as.modules.sys.service.SysUserService; +import org.apache.commons.lang.RandomStringUtils; +import org.apache.commons.lang.StringUtils; +import org.apache.shiro.crypto.hash.Sha256Hash; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service("sysUserService") +public class SysUserServiceImpl extends ServiceImpl implements SysUserService { + @Autowired + private SysUserRoleService sysUserRoleService; + @Autowired + private SysRoleService sysRoleService; + @Autowired + private SysOrgDao sysOrgDao; + + @Override + public PageUtils queryPage(Map params) { + String username = (String) params.get("username"); + IPage page = this.page( + new Query().getPage(params), + new QueryWrapper() + .like(StringUtils.isNotBlank(username), "username", username) + ); + List list = page.getRecords(); + List voList = new ArrayList<>(); + if (!list.isEmpty()) { + List orgIds = list.stream().map(SysUserEntity::getOrgId).filter(id -> id != null).distinct().collect(Collectors.toList()); + Map orgIdToName = orgIds.isEmpty() ? Collections.emptyMap() + : sysOrgDao.selectBatchIds(orgIds).stream().collect(Collectors.toMap(SysOrgEntity::getId, SysOrgEntity::getName, (a, b) -> a)); + int pageSize = (int) page.getSize(); + int currPage = (int) page.getCurrent(); + for (int i = 0; i < list.size(); i++) { + SysUserEntity user = list.get(i); + String orgName = user.getOrgId() != null ? orgIdToName.getOrDefault(user.getOrgId(), "") : "平台"; + voList.add(new SysUserListVo(user.getId(), user.getUsername(), (currPage - 1) * pageSize + i + 1, + user.getStatus(), user.getCreatedAt(), orgName, user.getMobile())); + } + } + return new PageUtils(voList, (int) page.getTotal(), (int) page.getSize(), (int) page.getCurrent()); + } + + @Override + public List queryAllPerms(Long userId) { + return baseMapper.queryAllPerms(userId); + } + + @Override + public List queryAllMenuId(Long userId) { + return baseMapper.queryAllMenuId(userId); + } + + @Override + public SysUserEntity queryByUserName(String username) { + return baseMapper.queryByUserName(username); + } + + @Override + @Transactional + public void saveUser(SysUserEntity user, Long operatorId) { + fillDefaultRoleByOrgIfNeeded(user); + user.setCreatedAt(new Date()); + String salt = RandomStringUtils.randomAlphanumeric(20); + user.setPassword(new Sha256Hash(user.getPassword(), salt).toHex()); + user.setSalt(salt); + this.save(user); + checkRole(user, operatorId); + sysUserRoleService.saveOrUpdate(user.getId(), user.getRoleIdList()); + } + + @Override + @Transactional + public void update(SysUserEntity user, Long operatorId) { + if (StringUtils.isBlank(user.getPassword())) { + user.setPassword(null); + } else { + user.setPassword(new Sha256Hash(user.getPassword(), user.getSalt()).toHex()); + } + user.setUpdatedAt(new Date()); + this.updateById(user); + checkRole(user, operatorId); + } + + @Override + public void deleteBatch(Long[] userId) { + this.removeByIds(Arrays.asList(userId)); + } + + @Override + public boolean updatePassword(Long userId, String password, String newPassword) { + SysUserEntity userEntity = new SysUserEntity(); + userEntity.setPassword(newPassword); + return this.update(userEntity, + new QueryWrapper().eq("id", userId).eq("password", password)); + } + + private void fillDefaultRoleByOrgIfNeeded(SysUserEntity user) { + if (user.getOrgId() == null) { + return; + } + if (user.getRoleIdList() != null && !user.getRoleIdList().isEmpty()) { + return; + } + SysOrgEntity org = sysOrgDao.selectById(user.getOrgId()); + if (org == null) { + return; + } + String roleName = OrgTypeRoleConstants.roleNameForOrgType(org.getType()); + if (roleName == null) { + return; + } + SysRoleEntity role = sysRoleService.lambdaQuery() + .eq(SysRoleEntity::getRoleName, roleName) + .last("LIMIT 1") + .one(); + if (role == null) { + throw new ASException("请先在「角色管理」中创建角色,名称须为「" + roleName + "」"); + } + user.setRoleIdList(Collections.singletonList(role.getId())); + } + + private void checkRole(SysUserEntity user, Long operatorId) { + if (user.getRoleIdList() == null || user.getRoleIdList().size() == 0) { + return; + } + SysUserEntity operator = this.getById(operatorId); + if (operator != null && Constant.isSuperAdmin(operator.getOrgId())) { + return; + } + List roleIdList = sysRoleService.queryRoleIdList(); + if (roleIdList != null && !roleIdList.containsAll(user.getRoleIdList())) { + throw new ASException("新增用户所选角色不合法"); + } + } +} diff --git a/src/main/java/io/as/modules/sys/service/impl/SysUserTokenServiceImpl.java b/src/main/java/io/as/modules/sys/service/impl/SysUserTokenServiceImpl.java new file mode 100644 index 0000000..858c9c8 --- /dev/null +++ b/src/main/java/io/as/modules/sys/service/impl/SysUserTokenServiceImpl.java @@ -0,0 +1,163 @@ +package io.as.modules.sys.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.utils.Constant; +import io.as.common.utils.R; +import io.as.modules.sys.dao.SysUserTokenDao; +import io.as.modules.sys.constants.OrgTypeRoleConstants; +import io.as.modules.sys.entity.SysRoleEntity; +import io.as.modules.sys.entity.SysUserEntity; +import io.as.modules.sys.entity.SysUserTokenEntity; +import io.as.modules.sys.jwt.TokenGenerator; +import io.as.modules.sys.service.SysRoleService; +import io.as.modules.sys.service.SysUserRoleService; +import io.as.modules.sys.service.SysUserService; +import io.as.modules.sys.service.SysUserTokenService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Service("sysUserTokenService") +public class SysUserTokenServiceImpl extends ServiceImpl implements SysUserTokenService { + @Autowired + private SysUserRoleService sysUserRoleService; + @Autowired + private SysRoleService sysRoleService; + @Autowired + private SysUserService sysUserService; + + private static final int ACCESS_EXPIRE = 3600 * 2; + private static final int REFRESH_EXPIRE = 3600 * 24 * 7; + private static final String ROLE_NAME_PLATFORM = "PLATFORM"; + + @Override + public R createToken(long userId) { + Date now = new Date(); + String accessToken = TokenGenerator.generateValue(); + String refreshToken = TokenGenerator.generateValue(); + Date accessExpireTime = new Date(now.getTime() + ACCESS_EXPIRE * 1000L); + Date refreshExpireTime = new Date(now.getTime() + REFRESH_EXPIRE * 1000L); + + SysUserTokenEntity tokenEntity = this.getById(userId); + if (tokenEntity == null) { + tokenEntity = new SysUserTokenEntity(); + tokenEntity.setUserId(userId); + tokenEntity.setToken(accessToken); + tokenEntity.setExpireTime(accessExpireTime); + tokenEntity.setRefreshToken(refreshToken); + tokenEntity.setRefreshExpireTime(refreshExpireTime); + tokenEntity.setUpdateTime(now); + this.save(tokenEntity); + } else { + tokenEntity.setToken(accessToken); + tokenEntity.setExpireTime(accessExpireTime); + tokenEntity.setRefreshToken(refreshToken); + tokenEntity.setRefreshExpireTime(refreshExpireTime); + tokenEntity.setUpdateTime(now); + this.updateById(tokenEntity); + } + + return R.ok() + .put("token", accessToken) + .put("expire", ACCESS_EXPIRE) + .put("refreshToken", refreshToken) + .put("refreshExpire", REFRESH_EXPIRE) + .put("roleNames", queryRoleNamesByUserId(userId)); + } + + private List queryRoleNamesByUserId(long userId) { + SysUserEntity user = sysUserService.getById(userId); + if (user != null && Constant.isSuperAdmin(user.getOrgId())) { + List platform = new ArrayList<>(1); + platform.add(ROLE_NAME_PLATFORM); + return platform; + } + List roleIdList = sysUserRoleService.queryRoleIdList(userId); + if (roleIdList == null || roleIdList.isEmpty()) { + return new ArrayList<>(); + } + Map idToName = new HashMap<>(); + for (SysRoleEntity role : sysRoleService.listByIds(roleIdList)) { + if (role != null && role.getRoleName() != null) { + idToName.put(role.getId(), role.getRoleName()); + } + } + List roleNames = new ArrayList<>(); + for (Long rid : roleIdList) { + String name = idToName.get(rid); + if (name != null) { + String orgMapped = OrgTypeRoleConstants.apiRoleCodeForDbRoleName(name); + if (orgMapped != null) { + roleNames.add(orgMapped); + } else { + roleNames.add(name); + } + } + } + return roleNames; + } + + @Override + public R refreshToken(String refreshToken) { + if (refreshToken == null || refreshToken.trim().isEmpty()) { + return R.error("refreshToken 不能为空"); + } + String trimmed = refreshToken.trim(); + + SysUserTokenEntity entity = baseMapper.queryByRefreshToken(trimmed); + if (entity == null) { + entity = baseMapper.queryByPreviousRefreshToken(trimmed); + if (entity != null) { + invalidateByUserId(entity.getUserId()); + return R.error(401, "检测到 refresh token 重复使用,出于安全已使该账号全部会话失效,请重新登录"); + } + return R.error("refreshToken 无效"); + } + + long nowMs = System.currentTimeMillis(); + if (entity.getRefreshExpireTime() == null || entity.getRefreshExpireTime().getTime() < nowMs) { + return R.error("refreshToken 已过期"); + } + + Date now = new Date(); + String newAccessToken = TokenGenerator.generateValue(); + String newRefreshToken = TokenGenerator.generateValue(); + Date accessExpireTime = new Date(now.getTime() + ACCESS_EXPIRE * 1000L); + Date refreshExpireTime = new Date(now.getTime() + REFRESH_EXPIRE * 1000L); + + int rows = baseMapper.updateTokenRotation( + entity.getUserId(), trimmed, + newAccessToken, accessExpireTime, + newRefreshToken, refreshExpireTime, + now); + if (rows == 0) { + return R.error("refreshToken 已失效,请使用最新的 refreshToken 重试或重新登录"); + } + + return R.ok() + .put("token", newAccessToken) + .put("expire", ACCESS_EXPIRE) + .put("refreshToken", newRefreshToken) + .put("refreshExpire", REFRESH_EXPIRE); + } + + @Override + public void logout(long userId) { + invalidateByUserId(userId); + } + + private void invalidateByUserId(long userId) { + SysUserTokenEntity entity = this.getById(userId); + if (entity == null) { + return; + } + entity.setToken(TokenGenerator.generateValue()); + entity.setExpireTime(new Date(0)); + entity.setRefreshToken(TokenGenerator.generateValue()); + entity.setRefreshExpireTime(new Date(0)); + entity.setPreviousRefreshToken(null); + entity.setUpdateTime(new Date()); + this.updateById(entity); + } +} diff --git a/src/main/java/io/as/modules/sys/utils/DeviceImportUtils.java b/src/main/java/io/as/modules/sys/utils/DeviceImportUtils.java new file mode 100755 index 0000000..39b4ae9 --- /dev/null +++ b/src/main/java/io/as/modules/sys/utils/DeviceImportUtils.java @@ -0,0 +1,32 @@ +package io.as.modules.sys.utils; + +/** + * 设备导入工具类(精简版,仅保留天卡项目需要的方法) + */ +public final class DeviceImportUtils { + + private DeviceImportUtils() { + } + + /** + * 将参数对象转为去空格后的字符串 + */ + public static String firstParamString(Object value) { + if (value == null) { + return null; + } + String s = String.valueOf(value).trim(); + return s.isEmpty() ? null : s; + } + + /** + * MAC 地址标准化:去除分隔符并转大写 + */ + public static String normalizeMac(String raw) { + if (raw == null) { + return null; + } + String s = raw.trim().replaceAll("[:\\-]", "").toUpperCase(); + return s.isEmpty() ? null : s; + } +} diff --git a/src/main/java/io/as/modules/sys/vo/ApiJstCallLogPageRowVo.java b/src/main/java/io/as/modules/sys/vo/ApiJstCallLogPageRowVo.java new file mode 100755 index 0000000..423e9a6 --- /dev/null +++ b/src/main/java/io/as/modules/sys/vo/ApiJstCallLogPageRowVo.java @@ -0,0 +1,40 @@ +package io.as.modules.sys.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 橘视厅第三方出站日志分页行:日志字段 + 关联设备标识 + 设备所属门店组织 + */ +@Data +public class ApiJstCallLogPageRowVo implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private String apiPath; + private String requestMethod; + private String requestBody; + private Integer responseCode; + private String responseBody; + private Integer success; + private Integer duration; + private Long dataId; + private String requestHeaders; + private String clientIp; + private String errorMessage; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime; + + /** 设备 MAC(来自 device.mac) */ + private String mac; + private String deviceSn; + private String deviceUid; + + private Long storeOrgId; + private String storeOrgName; + private String storeOrgCode; + private String storeOrgType; +} diff --git a/src/main/java/io/as/modules/sys/vo/CardStatsByTypeVo.java b/src/main/java/io/as/modules/sys/vo/CardStatsByTypeVo.java new file mode 100755 index 0000000..c6b31ad --- /dev/null +++ b/src/main/java/io/as/modules/sys/vo/CardStatsByTypeVo.java @@ -0,0 +1,32 @@ +package io.as.modules.sys.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 按卡类型统计的卡片数据 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CardStatsByTypeVo implements Serializable { + private static final long serialVersionUID = 1L; + + /** 卡类型:YEAR/QUARTER/MONTH/WEEK/OTHER */ + private String cardType; + + /** 卡类型名称 */ + private String cardTypeName; + + /** 可分配数量(IN_STOCK + UNUSED) */ + private long allocatableCount; + + /** 已激活数量(usage_status=ACTIVE) */ + private long activatedCount; + + /** 待激活数量 */ + private long pendingActivationCount; +} diff --git a/src/main/java/io/as/modules/sys/vo/CardTypeDetailStatsVo.java b/src/main/java/io/as/modules/sys/vo/CardTypeDetailStatsVo.java new file mode 100755 index 0000000..2e8a912 --- /dev/null +++ b/src/main/java/io/as/modules/sys/vo/CardTypeDetailStatsVo.java @@ -0,0 +1,50 @@ +package io.as.modules.sys.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 按卡类型的全量统计(年/月/天等) + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CardTypeDetailStatsVo implements Serializable { + private static final long serialVersionUID = 1L; + + /** 卡类型:YEAR/MONTH/OTHER */ + private String cardType; + + /** 卡类型名称 */ + private String cardTypeName; + + /** 累计采购总量:未删除卡总量(与导入入库口径一致) */ + private long totalPurchasedQuantity; + + /** 预置绑定数量:allocation_scene=PRE_IMPORT 且 usage_status=BOUND */ + private long presetBoundQuantity; + + /** 预置绑定已激活数量:allocation_scene=PRE_IMPORT 且已生效或已过期等终态 */ + private long presetBoundActivatedQuantity; + + /** 激活码分配数量:进入激活码预留池/分配链路的卡(allocation_scene=ACTIVE_CODE) */ + private long activationCodeAllocatedQuantity; + + /** 激活码激活数量:激活码链路下已生效或已过期等 */ + private long activationCodeActivatedQuantity; + + /** 激活异常数量:usage_status=ACTIVATION_FAILED */ + private long activationAbnormalQuantity; + + /** 待迁移数量:usage_status=WAITING_MIGRATION */ + private long waitingMigrationQuantity; + + /** + * 可用数量:全平台时为平台库存(IN_STOCK+UNUSED+org_id 为空+未过期); + * 按组织筛选时为该组织及下级范围内 IN_STOCK+UNUSED+未过期(与组织卡统计「可分配」一致) + */ + private long availableQuantity; +} diff --git a/src/main/java/io/as/modules/sys/vo/OrgCardStatsByTypeVo.java b/src/main/java/io/as/modules/sys/vo/OrgCardStatsByTypeVo.java new file mode 100755 index 0000000..6890aaa --- /dev/null +++ b/src/main/java/io/as/modules/sys/vo/OrgCardStatsByTypeVo.java @@ -0,0 +1,43 @@ +package io.as.modules.sys.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.List; + +/** + * 组织卡统计(按卡类型分类) + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OrgCardStatsByTypeVo implements Serializable { + private static final long serialVersionUID = 1L; + + /** 总计 */ + private TotalStats total; + + /** 按卡类型分类的统计列表 */ + private List byType; + + /** + * 总计统计 + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class TotalStats implements Serializable { + private static final long serialVersionUID = 1L; + + /** 可分配数量(IN_STOCK + UNUSED) */ + private long allocatableCount; + + /** 已激活数量(usage_status=ACTIVE) */ + private long activatedCount; + + /** 待激活数量 */ + private long pendingActivationCount; + } +} diff --git a/src/main/java/io/as/modules/sys/vo/OrgCardStatsVo.java b/src/main/java/io/as/modules/sys/vo/OrgCardStatsVo.java new file mode 100755 index 0000000..9851c9f --- /dev/null +++ b/src/main/java/io/as/modules/sys/vo/OrgCardStatsVo.java @@ -0,0 +1,24 @@ +package io.as.modules.sys.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 按组织维度的卡统计(该组织及子级) + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OrgCardStatsVo implements Serializable { + private static final long serialVersionUID = 1L; + + /** 渠道下可分配的卡数量(IN_STOCK + UNUSED) */ + private long allocatableCount; + /** 已激活数量(usage_status=ACTIVE) */ + private long activatedCount; + /** 待激活卡数量(预留池待领用或已绑定未激活) */ + private long pendingActivationCount; +} diff --git a/src/main/java/io/as/modules/sys/vo/OrgCardTypeAllocatableVo.java b/src/main/java/io/as/modules/sys/vo/OrgCardTypeAllocatableVo.java new file mode 100755 index 0000000..6164daa --- /dev/null +++ b/src/main/java/io/as/modules/sys/vo/OrgCardTypeAllocatableVo.java @@ -0,0 +1,26 @@ +package io.as.modules.sys.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 按卡类型的可分配卡数量(IN_STOCK + UNUSED,且未过期) + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OrgCardTypeAllocatableVo implements Serializable { + private static final long serialVersionUID = 1L; + + /** 卡类型:YEAR/QUARTER/MONTH/WEEK/OTHER */ + private String cardType; + + /** 卡类型名称 */ + private String cardTypeName; + + /** 可分配数量(库存中且未使用) */ + private long allocatableCount; +} diff --git a/src/main/java/io/as/modules/sys/vo/StoreCardTypeStatsVo.java b/src/main/java/io/as/modules/sys/vo/StoreCardTypeStatsVo.java new file mode 100755 index 0000000..19b543e --- /dev/null +++ b/src/main/java/io/as/modules/sys/vo/StoreCardTypeStatsVo.java @@ -0,0 +1,32 @@ +package io.as.modules.sys.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 门店按卡类型统计的卡片使用情况 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class StoreCardTypeStatsVo implements Serializable { + private static final long serialVersionUID = 1L; + + /** 卡类型:YEAR/QUARTER/MONTH/WEEK/OTHER */ + private String cardType; + + /** 卡类型名称 */ + private String cardTypeName; + + /** 该类型卡总数 */ + private long totalCount; + + /** 已使用数量(usage_status 不为 UNUSED) */ + private long usedCount; + + /** 未使用数量(usage_status = UNUSED) */ + private long unusedCount; +} diff --git a/src/main/java/io/as/modules/sys/vo/SysUserListVo.java b/src/main/java/io/as/modules/sys/vo/SysUserListVo.java new file mode 100644 index 0000000..c64c863 --- /dev/null +++ b/src/main/java/io/as/modules/sys/vo/SysUserListVo.java @@ -0,0 +1,22 @@ +package io.as.modules.sys.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.Date; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SysUserListVo implements Serializable { + private static final long serialVersionUID = 1L; + private Long id; + private String username; + private Integer index; + private Integer status; + private Date createdAt; + private String orgName; + private String mobile; +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..a8ea7f1 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,43 @@ +spring: + datasource: + type: com.alibaba.druid.pool.DruidDataSource + druid: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL:jdbc:mysql://127.0.0.1:3306/dev-0418?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai} + username: ${DB_USERNAME:root} + password: ${DB_PASSWORD:r2356510} + initial-size: 10 + max-active: 100 + min-idle: 10 + max-wait: 60000 + pool-prepared-statements: true + max-pool-prepared-statement-per-connection-size: 20 + time-between-eviction-runs-millis: 60000 + min-evictable-idle-time-millis: 300000 + test-while-idle: true + test-on-borrow: false + test-on-return: false + stat-view-servlet: + enabled: true + url-pattern: /druid/* + filter: + stat: + log-slow-sql: true + slow-sql-millis: 1000 + merge-sql: false + wall: + enabled: false + + redis: + open: ${REDIS_OPEN:true} + database: ${REDIS_DATABASE:0} + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + timeout: ${REDIS_TIMEOUT:6000ms} + jedis: + pool: + max-active: ${REDIS_POOL_MAX_ACTIVE:1000} + max-wait: ${REDIS_POOL_MAX_WAIT:-1ms} + max-idle: ${REDIS_POOL_MAX_IDLE:10} + min-idle: ${REDIS_POOL_MIN_IDLE:5} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..d4899a4 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,101 @@ +# Tomcat +server: + tomcat: + uri-encoding: UTF-8 + threads: + max: 1000 + min-spare: 30 + connection-timeout: 5000ms + basedir: . + accesslog: + enabled: true + pattern: '%t %a "%r" %s %b %Dms' + suffix: .log + prefix: access_log + directory: logs + port: ${SERVER_PORT:8084} + servlet: + context-path: / + +spring: + # 环境 dev|test|prod,通过环境变量 SPRING_PROFILES_ACTIVE 指定 + profiles: + active: ${SPRING_PROFILES_ACTIVE:dev} + # jackson时间格式化 + jackson: + time-zone: Asia/Shanghai + date-format: yyyy-MM-dd HH:mm:ss + servlet: + multipart: + max-file-size: 100MB + max-request-size: 100MB + enabled: true + + mvc: + throw-exception-if-no-handler-found: true + pathmatch: + matching-strategy: ANT_PATH_MATCHER + +#mybatis +mybatis-plus: + mapper-locations: classpath*:/mapper/**/*.xml + #实体扫描,多个package用逗号或者分号分隔 + typeAliasesPackage: io.as.modules.*.entity,io.as.modules.*.entity,io.as.modules.device.entity,io.as.modules.jst.entity + global-config: + db-config: + id-type: AUTO + logic-delete-value: -1 + logic-not-delete-value: 0 + banner: false + configuration: + map-underscore-to-camel-case: true + cache-enabled: false + call-setters-on-nulls: true + jdbc-type-for-null: 'null' + +renren: + redis: + open: ${REDIS_OPEN:true} + jwt: + secret: ${JWT_SECRET:daycard-openapi-secret-change-in-prod-2026} + expire: ${JWT_EXPIRE:604800} + header: ${JWT_HEADER:token} + api: + device-sign-secret: ${API_SIGN_SECRET:daycard-partner-sign-secret-change-in-prod} + # 天卡业务配置 + daycard: + # 订单超时时间(分钟) + order-timeout-minutes: ${DAYCARD_ORDER_TIMEOUT:15} + # 天卡有效期(小时) + card-valid-hours: ${DAYCARD_VALID_HOURS:24} + # 超时取消任务每次处理数量 + cancel-batch-size: ${DAYCARD_CANCEL_BATCH:200} + # App UI 资源配置(顶栏按钮图标、登录背景图) + ui: + logo-url: ${UI_LOGO_URL:https://daycard.oss-cn-hangzhou.aliyuncs.com/logo.png} + login-bg-url: ${UI_LOGIN_BG_URL:https://daycard.oss-cn-hangzhou.aliyuncs.com/login_bg.png} + search-normal-url: ${UI_SEARCH_NORMAL:https://daycard.oss-cn-hangzhou.aliyuncs.com/search_normal.png} + search-focus-url: ${UI_SEARCH_FOCUS:https://daycard.oss-cn-hangzhou.aliyuncs.com/search_focus.png} + help-normal-url: ${UI_HELP_NORMAL:https://daycard.oss-cn-hangzhou.aliyuncs.com/help_normal.png} + help-focus-url: ${UI_HELP_FOCUS:https://daycard.oss-cn-hangzhou.aliyuncs.com/help_focus.png} + # 微信支付配置 + wechat: + pay: + app-id: ${WECHAT_PAY_APP_ID:} + mch-id: ${WECHAT_PAY_MCH_ID:} + api-v3-key: ${WECHAT_PAY_API_V3_KEY:} + cert-serial-no: ${WECHAT_PAY_CERT_SERIAL_NO:} + private-key-path: ${WECHAT_PAY_PRIVATE_KEY_PATH:} + notify-url: ${WECHAT_PAY_NOTIFY_URL:} + +# 橘视厅媒资接口配置(对应 Android 端 ApiConfig.kt) +jetsen: + media: + base-url: ${JETSEN_MEDIA_BASE_URL:https://servhub-ep03.future.jetsentv.com/} + path-prefix: ${JETSEN_MEDIA_PATH_PREFIX:/IPTVPortalInterface} + sp-token: ${JETSEN_MEDIA_SP_TOKEN:9006874392099a89506142c6df9e3314} + portal-code: ${JETSEN_MEDIA_PORTAL_CODE:PCODE-JC} + sign-key: ${JETSEN_MEDIA_SIGN_KEY:alltvjs} + channel-id: ${JETSEN_MEDIA_CHANNEL_ID:001} + export-page-size: ${JETSEN_MEDIA_EXPORT_PAGE_SIZE:50} + export-max-rows: ${JETSEN_MEDIA_EXPORT_MAX_ROWS:5000} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100755 index 0000000..6fb26ec --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,47 @@ + + + + + + + logs/app.log + + logs/app.%d{yyyy-MM-dd}.log + 30 + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n + UTF-8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/mapper/daycard/DayCardPoolDao.xml b/src/main/resources/mapper/daycard/DayCardPoolDao.xml new file mode 100644 index 0000000..57edac8 --- /dev/null +++ b/src/main/resources/mapper/daycard/DayCardPoolDao.xml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/src/main/resources/mapper/device/CardDeviceBindingDao.xml b/src/main/resources/mapper/device/CardDeviceBindingDao.xml new file mode 100755 index 0000000..c7e3aca --- /dev/null +++ b/src/main/resources/mapper/device/CardDeviceBindingDao.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/device/DeviceDao.xml b/src/main/resources/mapper/device/DeviceDao.xml new file mode 100755 index 0000000..4929e28 --- /dev/null +++ b/src/main/resources/mapper/device/DeviceDao.xml @@ -0,0 +1,351 @@ + + + + + + + d.id, d.store_org_id, d.mac, d.device_sn, d.wifi_mac, d.device_uid, d.identifier_strategy, + d.device_name, d.device_model, d.ver_code, d.ver_name, d.android_version, d.room_no, + d.last_heartbeat, d.remark, d.status, d.delete_flag, d.created_at, d.updated_at + + + + + + + + + + + + + + diff --git a/src/main/resources/mapper/jst/ApiJstCallLogDao.xml b/src/main/resources/mapper/jst/ApiJstCallLogDao.xml new file mode 100755 index 0000000..621f120 --- /dev/null +++ b/src/main/resources/mapper/jst/ApiJstCallLogDao.xml @@ -0,0 +1,53 @@ + + + + + + + diff --git a/src/main/resources/mapper/sys/StoreProfileDao.xml b/src/main/resources/mapper/sys/StoreProfileDao.xml new file mode 100755 index 0000000..c39dc71 --- /dev/null +++ b/src/main/resources/mapper/sys/StoreProfileDao.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/mapper/sys/SysConfigDao.xml b/src/main/resources/mapper/sys/SysConfigDao.xml new file mode 100644 index 0000000..db3e6c9 --- /dev/null +++ b/src/main/resources/mapper/sys/SysConfigDao.xml @@ -0,0 +1,19 @@ + + + + + + + + + UPDATE sys_config + SET param_value = #{paramValue} + WHERE param_key = #{paramKey} + + + diff --git a/src/main/resources/mapper/sys/SysMenuDao.xml b/src/main/resources/mapper/sys/SysMenuDao.xml new file mode 100644 index 0000000..321bc5e --- /dev/null +++ b/src/main/resources/mapper/sys/SysMenuDao.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/src/main/resources/mapper/sys/SysOrgDao.xml b/src/main/resources/mapper/sys/SysOrgDao.xml new file mode 100755 index 0000000..1b2a561 --- /dev/null +++ b/src/main/resources/mapper/sys/SysOrgDao.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + diff --git a/src/main/resources/mapper/sys/SysRoleDao.xml b/src/main/resources/mapper/sys/SysRoleDao.xml new file mode 100644 index 0000000..8432562 --- /dev/null +++ b/src/main/resources/mapper/sys/SysRoleDao.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/src/main/resources/mapper/sys/SysRoleMenuDao.xml b/src/main/resources/mapper/sys/SysRoleMenuDao.xml new file mode 100644 index 0000000..c53ff50 --- /dev/null +++ b/src/main/resources/mapper/sys/SysRoleMenuDao.xml @@ -0,0 +1,13 @@ + + + + + + delete from sys_role_menu where role_id in + + #{roleId} + + + diff --git a/src/main/resources/mapper/sys/SysUserDao.xml b/src/main/resources/mapper/sys/SysUserDao.xml new file mode 100644 index 0000000..49dd04f --- /dev/null +++ b/src/main/resources/mapper/sys/SysUserDao.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/src/main/resources/mapper/sys/SysUserRoleDao.xml b/src/main/resources/mapper/sys/SysUserRoleDao.xml new file mode 100644 index 0000000..b76c479 --- /dev/null +++ b/src/main/resources/mapper/sys/SysUserRoleDao.xml @@ -0,0 +1,13 @@ + + + + + delete from sys_user_role where role_id in + + #{roleId} + + + + diff --git a/src/main/resources/mapper/sys/SysUserTokenDao.xml b/src/main/resources/mapper/sys/SysUserTokenDao.xml new file mode 100644 index 0000000..a9c5d8a --- /dev/null +++ b/src/main/resources/mapper/sys/SysUserTokenDao.xml @@ -0,0 +1,24 @@ + + + + + + + + update sys_user_token + set token = #{newToken}, + expire_time = #{newExpireTime}, + refresh_token = #{newRefreshToken}, + refresh_expire_time = #{newRefreshExpireTime}, + previous_refresh_token = #{currentRefreshToken}, + update_time = #{updateTime} + where user_id = #{userId} + and refresh_token = #{currentRefreshToken} + + diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..805ea17 --- /dev/null +++ b/start.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# =========================================================== +# start.sh — daycard-openapi 后端一键启动脚本 +# 项目: Spring Boot 2.6.6 / Java 17 / 端口 8084 +# +# 用法: +# ./start.sh # clean 后启动(推荐) +# ./start.sh --no-clean # 跳过 clean,加速重复启动 +# ./start.sh -Dspring-boot.run.jvmArguments="-Dserver.port=8090" +# # 透传任意 maven / spring-boot 参数 +# =========================================================== +set -euo pipefail +cd "$(dirname "$0")" + +# ---- 1. 选定 Java 17(找不到则回退 Java 8)---- +JDK17_PREF="/Users/zhaoguodong/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home" +if [[ -d "$JDK17_PREF" ]]; then + export JAVA_HOME="$JDK17_PREF" +elif JDK17_AUTO="$(/usr/libexec/java_home -v 17 2>/dev/null)"; then + export JAVA_HOME="$JDK17_AUTO" +elif JDK8_AUTO="$(/usr/libexec/java_home -v 1.8 2>/dev/null)"; then + export JAVA_HOME="$JDK8_AUTO" + echo "⚠️ 未找到 Java 17,回退到 Java 8" >&2 +else + echo "❌ 找不到 Java 17 或 Java 8,请安装 JDK 或修改本脚本里的 JAVA_HOME" >&2 + exit 1 +fi +echo "==> JAVA_HOME=$JAVA_HOME" +"$JAVA_HOME/bin/java" -version + +# ---- 2. 解析参数 ---- +DO_CLEAN=1 +PASSTHRU=() +for arg in "$@"; do + case "$arg" in + --no-clean) DO_CLEAN=0 ;; + --clean) DO_CLEAN=1 ;; + *) PASSTHRU+=("$arg") ;; + esac +done + +# ---- 3. 启动 ---- +# clean 可清掉 target 中残留的 stale class(曾出现 AsApplication 2.class +# 导致“找到两个 main class”无法启动的问题)。 +GOAL="spring-boot:run" +if [[ "$DO_CLEAN" -eq 1 ]]; then + GOAL="clean spring-boot:run" + echo "==> mvn clean spring-boot:run" +else + echo "==> mvn spring-boot:run" +fi +mvn $GOAL "${PASSTHRU[@]+"${PASSTHRU[@]}"}" diff --git a/橘视厅酒店版openapi文档.md b/橘视厅酒店版openapi文档.md new file mode 100644 index 0000000..b7a71e4 --- /dev/null +++ b/橘视厅酒店版openapi文档.md @@ -0,0 +1,481 @@ +# 天卡开放平台 第三方对接文档 + +**版本**: V2.0 +**更新日期**: 2026-07-23 + +--- + +## 1. 概述 + +本文档面向第三方合作伙伴后端开发人员,描述天卡开放平台的接口规范。 + +### 1.1 业务流程 + +``` +第三方EPG 开放平台 APK + | | | + | 1. 用户在EPG界面完成支付 | | + | | | + | 2. 调用消耗接口(发卡) | | + |---POST /card/consume------->| | + |<---deviceId, expireAt-------| | + | status=ACTIVATING | | + | | 3. 异步激活(橘视厅SDK) | + | | | + | 4. EPG传参启动APK | | + |---设备标识(mac/sn等)-------> | | + | | 5. APK调init获取sign | + | |<---POST /api/jst/init-------| + | |----sign------------------->| + | | 6. APK轮询checkBenefits | + | |<---POST /checkBenefits------| + | |----thirdCardNo------------>| + | | (status=ACTIVE时返回) | + | | 7. APK用thirdCardNo播放 | +``` + +### 1.2 服务器地址 + +| 环境 | 地址 | +|------|------| +| 生产环境 | http://58.220.83.58:8084 | + +--- + +## 2. 鉴权方式 + +### 2.1 凭证 + +平台分配的 `appKey` 和 `appSecret`,由管理后台创建合作伙伴时自动生成。 + +### 2.2 签名算法 + +``` +sign = MD5(appKey + timestamp + nonce + appSecret).toUpperCase() +``` + +- 拼接顺序固定:appKey + timestamp + nonce + appSecret,无分隔符 +- MD5 结果转大写 + +### 2.3 请求头 + +每个请求必须携带以下 HTTP 请求头: + +| 请求头 | 类型 | 说明 | +|--------|------|------| +| appKey | String | 平台分配的应用 Key | +| timestamp | String | 毫秒级时间戳,有效期 5 分钟 | +| nonce | String | 随机字符串,5 分钟内不可重复 | +| sign | String | 签名(大写 MD5) | + +### 2.4 鉴权失败响应 + +HTTP 401: + +```json +{"code": 401, "msg": "签名校验失败"} +``` + +### 2.5 统一响应格式 + +```json +{"code": 0, "msg": "success", "data": {}} +``` + +- `code = 0` 表示成功,非 0 表示失败 +- 业务数据在 `data` 字段内 + +--- + +## 3. 天卡代销接口 + +### 3.1 消耗天卡 + +用户在 EPG 完成支付后调用,从合作伙伴天卡池分配 1 张天卡绑定到设备,并异步激活。 + +``` +POST /openapi/partner/card/consume +Content-Type: application/json +``` + +**请求体**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| mac | String | 否* | 设备 MAC 地址 | +| wifiMac | String | 否* | WiFi MAC 地址 | +| deviceUid | String | 是* | 设备唯一标识 | +| deviceSn | String | 否* | 设备序列号 | +| channelOrgId | Long | 否 | 渠道组织 ID | + +\* mac / wifiMac / deviceUid / deviceSn +deviceUid必须传,mac / wifimac /deviceSn +至少传一个,需与 APK 启动时传入的设备标识一致。 + +**请求示例**: + +```json +{ + "mac": "AA:BB:CC:DD:EE:01", + "deviceSn": "SN-001", + "deviceUid": "UID-001" +} +``` + +**响应**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| deviceId | Long | 设备 ID | +| expireAt | String | 天卡过期时间(yyyy-MM-dd HH:mm:ss),初始为 24 小时,异步激活成功后更新为真实过期时间 | +| status | String | 天卡状态:ACTIVATING(激活中)| + +**响应示例**: + +```json +{ + "code": 0, + "msg": "success", + "data": { + "deviceId": 123, + "expireAt": "2026-07-24 14:00:00", + "status": "ACTIVATING" + } +} +``` + +**幂等说明**:同一设备重复调用时不会消耗新天卡,直接返回已有天卡信息。若天卡已激活成功,status 返回 `ACTIVE`。 + +**错误响应**: + +| code | msg | 说明 | +|------|-----|------| +| 400 | mac/wifiMac/deviceUid/deviceSn 至少传一个 | 缺少设备标识 | +| 400 | 天卡池库存不足 | 可用天卡已耗尽 | +| 400 | 操作太频繁,请稍后再试 | 同一设备短时间内重复请求 | +| 500 | 天卡分配失败,请重试 | 系统繁忙,请重试 | + +### 3.2 查询天卡库存 + +``` +GET /openapi/partner/card/stock +``` + +**响应示例**: + +```json +{ + "code": 0, + "msg": "success", + "data": { + "available": 50 + } +} +``` + +--- + +## 4. 媒资接口 + +### 4.1 查看授权范围 + +``` +GET /openapi/partner/media/package +``` + +**响应**: + +| 字段 | 说明 | +|------|------| +| scope | 授权级别:CONTENT(指定内容)/ CATALOG(指定栏目)/ FULL(全量)| +| catalogIds | 授权栏目 ID(逗号分隔,CATALOG 级别有值)| +| contentCount | 授权内容数量 | + +### 4.2 栏目树 + +``` +GET /openapi/partner/media/catalog-tree +``` + +按授权范围过滤返回: +- CONTENT 级别返回空树 +- CATALOG 级别只返回授权栏目 +- FULL 级别返回完整栏目树 + +### 4.3 内容列表 + +``` +GET /openapi/partner/media/list?catalogid=472&pageindex=1&pagesize=20 +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| catalogid | String | FULL/CATALOG 级别必填 | 栏目 ID | +| pageindex | int | 否(默认 1)| 页码 | +| pagesize | int | 否(默认 20)| 每页条数 | + +- CONTENT 级别:返回授权内容列表,不需传 catalogid +- CATALOG 级别:必须传授权范围内的 catalogid +- FULL 级别:必须传 catalogid + +### 4.4 搜索 + +``` +GET /openapi/partner/media/search?keyword=镖人&pageindex=1&pagesize=20 +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| keyword | String | 是 | 搜索关键词 | +| pageindex | int | 否(默认 1)| 页码 | +| pagesize | int | 否(默认 20)| 每页条数 | + +### 4.5 内容字段说明 + +| 字段 | 说明 | +|------|------| +| contentcode | 内容唯一编码 | +| name | 内容名称 | +| posterimg | 海报图片 URL | +| categoryname | 分类名称 | +| score | 评分 | +| isFree | 1 = 免费,0 = 付费 | +| episodecount | 集数 | +| duration | 时长(秒)| +| releaseyear | 上映年份 | +| originalcountry | 地区 | + +--- + +## 5. APK 侧流程说明 + +以下接口由 APK 调用,第三方无需直接调用,但需了解流程以便 EPG 传参启动 APK。 + +### 5.1 设备初始化 + +APK 使用消耗接口传入的相同设备标识调用初始化: + +``` +POST /api/jst/init +Content-Type: application/json +``` + +请求体(不传 activationCode,代销模式): + +```json +{ + "mac": "AA:BB:CC:DD:EE:01", + "wifiMac": "...", + "deviceSn": "SN-001", + "deviceUid": "UID-001" +} +``` + +返回 `sign`(设备鉴权凭证,24 小时有效)。 + +### 5.2 查询天卡权益 + +APK 轮询查询激活状态,仅当 status 为 ACTIVE 时获取播放凭证: + +``` +POST /api/jst/checkBenefits +Header: sign: +``` + +**响应**: + +| status | 说明 | 是否返回 thirdCardNo | +|--------|------|---------------------| +| ACTIVATING | 异步激活中,需继续轮询 | 否 | +| ACTIVE | 激活成功,可播放 | 是 | +| ACTIVATION_FAILED | 激活失败 | 否 | +| UNBOUND | 无天卡绑定 | 否 | + +**轮询建议**:间隔 2 秒,超时 5 分钟。 + +### 5.3 播放 + +APK 使用 `thirdCardNo` 作为播放凭证初始化橘视厅 SDK,获取播放地址后播放。 + +--- + +## 6. 签名示例 + +### Java + +```java +String appKey = "平台分配的appKey"; +String appSecret = "平台分配的appSecret"; +String timestamp = String.valueOf(System.currentTimeMillis()); +String nonce = UUID.randomUUID().toString().replace("-", ""); +String sign = DigestUtils.md5Hex(appKey + timestamp + nonce + appSecret).toUpperCase(); +``` + +### Python + +```python +import hashlib, time, uuid + +timestamp = str(int(time.time() * 1000)) +nonce = uuid.uuid4().hex +sign = hashlib.md5((app_key + timestamp + nonce + app_secret).encode()).hexdigest().upper() +``` + +### Node.js + +```javascript +const crypto = require('crypto'); +const timestamp = Date.now().toString(); +const nonce = crypto.randomBytes(16).toString('hex'); +const sign = crypto.createHash('md5') + .update(appKey + timestamp + nonce + appSecret) + .digest('hex').toUpperCase(); +``` + +--- + +## 7. 请求示例(curl) + +以下示例中的 `appKey`、`appSecret` 需替换为平台分配的实际值。`timestamp`、`nonce`、`sign` 需按签名算法实时生成。 + +### 7.1 消耗天卡 + +```bash +curl -X POST "http://58.220.83.58:8084/openapi/partner/card/consume" \ + -H "Content-Type: application/json" \ + -H "appKey: 你的appKey" \ + -H "timestamp: 1784809605695" \ + -H "nonce: 1784809605695aaf094ef" \ + -H "sign: A6EBB96858ED2FAD864CB39568E3EABB" \ + -d '{"mac":"AA:BB:CC:DD:EE:01","deviceSn":"SN-001","deviceUid":"UID-001"}' +``` + +响应: +```json +{ + "code": 0, + "msg": "success", + "data": { + "deviceId": 123, + "expireAt": "2026-07-24 20:26:46", + "status": "ACTIVATING" + } +} +``` + +### 7.2 查询天卡库存 + +```bash +curl -X GET "http://58.220.83.58:8084/openapi/partner/card/stock" \ + -H "appKey: 你的appKey" \ + -H "timestamp: 1784809605695" \ + -H "nonce: 1784809605695aaf094ef" \ + -H "sign: A6EBB96858ED2FAD864CB39568E3EABB" +``` + +### 7.3 查看授权范围 + +```bash +curl -X GET "http://58.220.83.58:8084/openapi/partner/media/package" \ + -H "appKey: 你的appKey" \ + -H "timestamp: 1784809605695" \ + -H "nonce: 1784809605695aaf094ef" \ + -H "sign: A6EBB96858ED2FAD864CB39568E3EABB" +``` + +### 7.4 栏目树 + +```bash +curl -X GET "http://58.220.83.58:8084/openapi/partner/media/catalog-tree" \ + -H "appKey: 你的appKey" \ + -H "timestamp: 1784809605695" \ + -H "nonce: 1784809605695aaf094ef" \ + -H "sign: A6EBB96858ED2FAD864CB39568E3EABB" +``` + +### 7.5 内容列表 + +```bash +curl -X GET "http://58.220.83.58:8084/openapi/partner/media/list?catalogid=472&pageindex=1&pagesize=20" \ + -H "appKey: 你的appKey" \ + -H "timestamp: 1784809605695" \ + -H "nonce: 1784809605695aaf094ef" \ + -H "sign: A6EBB96858ED2FAD864CB39568E3EABB" +``` + +### 7.6 搜索 + +```bash +curl -X GET "http://58.220.83.58:8084/openapi/partner/media/search?keyword=镖人&pageindex=1&pagesize=20" \ + -H "appKey: 你的appKey" \ + -H "timestamp: 1784809605695" \ + -H "nonce: 1784809605695aaf094ef" \ + -H "sign: A6EBB96858ED2FAD864CB39568E3EABB" +``` + +### 7.7 签名生成示例(bash) + +```bash +APP_KEY="你的appKey" +APP_SECRET="你的appSecret" +TIMESTAMP=$(date +%s%3N) +NONCE="${TIMESTAMP}$(head -c 8 /dev/urandom | xxd -p)" +SIGN=$(echo -n "${APP_KEY}${TIMESTAMP}${NONCE}${APP_SECRET}" | md5sum | awk '{print toupper($0)}') + +echo "appKey: $APP_KEY" +echo "timestamp: $TIMESTAMP" +echo "nonce: $NONCE" +echo "sign: $SIGN" + +# 直接用变量发请求 +curl -X POST "http://58.220.83.58:8084/openapi/partner/card/consume" \ + -H "Content-Type: application/json" \ + -H "appKey: $APP_KEY" \ + -H "timestamp: $TIMESTAMP" \ + -H "nonce: $NONCE" \ + -H "sign: $SIGN" \ + -d '{"mac":"AA:BB:CC:DD:EE:01","deviceSn":"SN-001"}' +``` + +--- + +## 8. 注意事项 + +1. **nonce 唯一性**:每个请求的 nonce 必须唯一,5 分钟内重复提交会被拒绝。 +2. **timestamp 有效期**:与服务器时间差超过 5 分钟会过期。 +3. **设备标识一致性**:消耗接口传入的设备标识必须与 APK 启动时传入的一致,否则 APK 无法找到设备。 +4. **异步激活**:消耗接口返回后天卡异步激活(通常 2-5 秒),APK 需轮询 checkBenefits 确认激活完成。 +5. **库存管理**:消耗接口返回库存不足错误时,需联系平台采购天卡。 + +--- + +## 附录:设备标识说明 + +### deviceUid 生成规则 + +APK 的 `deviceUid` 按以下优先级生成: + +| 优先级 | 来源 | 格式 | 重装不变 | 工厂重置不变 | +|--------|------|------|----------|-------------| +| 1 | Android ID(Settings.Secure.ANDROID_ID) | 64 位 hex(16 字符),如 `f0f179fe199f7a52` | 是 | 否 | +| 2 | UUID(兜底,存 SharedPreferences) | 32 位无横线,如 `a1b2c3d4e5f6...` | 否 | 否 | + +Android ID 为空时使用 UUID 兜底。正常情况下 deviceUid 即 Android ID。 + +### 关键注意点 + +1. **consume 与 init 的 deviceUid 必须一致**:第三方调用消耗接口时传入的 `deviceUid` 必须与 APK 调用 init 时传入的 `deviceUid` 相同,否则 APK 无法找到设备。 + +2. **Android ID 按签名隔离**:Android 8+ 系统中,不同签名的 App 在同一设备上获取的 Android ID 不同。因此 EPG 获取 deviceUid 的方式需与 APK 一致,建议由 APK 提供给 EPG,而非 EPG 自行获取。 + +3. **推荐传参方式**:EPG 启动 APK 时通过 Intent 传递设备标识,APK 使用相同标识调 init 接口。设备标识建议优先使用 `mac`,其次 `deviceUid`。 + +### 设备标识匹配优先级 + +消耗接口和 init 接口均按以下优先级匹配设备: + +``` +mac > wifiMac > deviceUid > deviceSn +``` + +只要有一个标识匹配即可找到设备。建议 consume 和 init 传递相同的标识组合。 diff --git a/第三方App测试需求.md b/第三方App测试需求.md new file mode 100644 index 0000000..d0cb47e --- /dev/null +++ b/第三方App测试需求.md @@ -0,0 +1,258 @@ +# 第三方合作伙伴 App 测试需求 + +## 1. 目标 + +验证代销模式完整链路:EPG启动App -> init -> checkBenefits -> SDK初始化 -> 获取播放地址 -> 播放 + +## 2. App 定位 + +Android TV 应用,由第三方 EPG 通过 Intent 传参启动。App 不负责支付,仅负责: +- 设备初始化(获取 sign) +- 查询天卡权益(获取 thirdCardNo) +- 橘视厅 SDK 初始化 +- 内容浏览与播放 + +## 3. 启动方式 + +### 3.1 EPG 传参启动(Intent) + +``` +Intent action: com.pukka.mediaapp.PARTNER_LAUNCH +Extras: + - mac (String, 设备MAC) + - wifiMac (String, WiFi MAC, 可选) + - deviceSn (String, 设备SN, 可选) + - deviceUid (String, 设备UID, 可选) +``` + +至少传一个设备标识(mac/wifiMac/deviceSn/deviceUid)。 + +### 3.2 手动输入模式(调试用) + +App 提供一个输入页面,可手动输入设备标识,方便无 EPG 环境时测试。 + +## 4. 后端接口 + +### 4.1 配置 + +``` +后端地址: http://58.220.83.58:8084 (可配置) +``` + +### 4.2 设备初始化 + +``` +POST /api/jst/init +Content-Type: application/json +Body: { "mac": "xx", "wifiMac": "xx", "deviceSn": "xx", "deviceUid": "xx" } + (不传 activationCode -> 代销模式) + +成功返回: +{ + "code": 0, + "msg": "success", + "data": { "device": { "deviceId": 9, "roomNo": null } }, + "sign": "Base64编码的sign字符串" +} + +失败返回: +{ + "code": 400, + "msg": "设备未注册,请先通过合作伙伴天卡消耗接口激活设备", + "data": {} +} +``` + +### 4.3 查询天卡权益 + +``` +POST /api/jst/checkBenefits +Header: sign: <上一步获取的sign> + +成功返回: +{ + "code": 0, + "msg": "success", + "data": { + "status": "ACTIVE", // ACTIVE=已激活 UNBOUND=未绑定/激活中 + "thirdCardNo": "DKMMqjJKgpdwUahip5l4hGzjS", // 播放凭证 + "cardNo": "CEB6258DB4AD6B06", + "expireAt": "2026-07-24 19:33:14" + } +} +``` + +### 4.4 轮询策略 + +checkBenefits 可能返回 `status: "UNBOUND"`(异步激活未完成),需要轮询: +- 轮询间隔:2秒 +- 轮询超时:5分钟 +- 收到 `ACTIVE` 后停止轮询 + +### 4.5 心跳续期(可选) + +``` +POST /api/jst/heartBeat +Header: sign: +``` +建议每 30 分钟调用一次,防止 sign 过期(24小时有效)。 + +## 5. 橘视厅 SDK 初始化 + +### 5.1 SDK 配置 + +``` +AGCODE = "1100101010200492358120169" +TENANT = "huashitv" +ACCESSER = "JSTASJD" +SECRET = "kMeWsBu0QsTXpnft" +SERVICE_COMBO_CODE = "1100121010200494755850172" +``` + +### 5.2 初始化流程 + +checkBenefits 返回 `ACTIVE` 后,用 `thirdCardNo` 作为 uid 初始化 SDK: + +```kotlin +JetsenxSdk.init( + context, + agcode = AGCODE, + mac = <设备MAC>, + uid = thirdCardNo, // 关键:用 thirdCardNo 作为 uid + tenant = TENANT, + secret = SECRET, + accesser = ACCESSER, + utype = "2", // uid非MAC格式时用"2",MAC格式用"3" + "", + serviceComboCode = SERVICE_COMBO_CODE, + callback = ... +) +``` + +### 5.3 SDK 库文件 + +``` +app/libs/ + - JetsenxSdk-api-future-release-v1.23.aar + - NewTVSDK-v4.0.3-app-lite.jar + - arm64-v8a/libnewtvsdk.so + - armeabi-v7a/libnewtvsdk.so +``` + +## 6. 内容浏览与播放 + +### 6.1 内容列表(可选) + +可调后端媒资接口获取内容列表: +``` +GET /openapi/partner/media/list?catalogid=xxx&pageindex=1&pagesize=20 +Header: appKey/timestamp/nonce/sign (合作伙伴签名鉴权) +``` + +或直接用橘视厅媒资接口(App 已有 ApiConfig 配置)。 + +### 6.2 获取播放地址 + +SDK 初始化成功后,用节目 code 获取播放地址: + +```kotlin +// 1. 获取 productCode +JetsenxSdk.getAllAssetPrices(accesser, secret, callback) + +// 2. 获取播放地址 +JetsenxSdk.getVideoUrls(productCode, programCode, callback) +// 或免费内容 +JetsenxSdk.getFreeVideoUrls(programCode, callback) +``` + +### 6.3 播放 + +用获取到的 videoUrl 播放(可用 ExoPlayer 或项目中已有的 HyMediaPlayer)。 + +## 7. 页面结构(最小化) + +``` +页面1: 启动页/输入页 + - 显示传入的设备标识(或手动输入) + - "初始化"按钮 + - 显示 init 结果(sign, deviceId) + +页面2: 权益查询页 + - 自动轮询 checkBenefits + - 显示 status (ACTIVATING -> ACTIVE) + - 显示 thirdCardNo, expireAt + - ACTIVE 后自动初始化 SDK + - 显示 SDK init 结果 + +页面3: 播放测试页 + - 输入 programCode(或从内容列表选择) + - "获取播放地址"按钮 + - 显示播放地址 + - "播放"按钮 + - 视频播放区域 +``` + +## 8. 测试场景 + +### 场景1: 完整流程(正常) + +``` +前置: 第三方已调 consume 接口,设备已绑定天卡 + +1. EPG传参启动App (mac=AA:BB:CC:DD:EE:01) +2. App调 init -> 返回 sign +3. App调 checkBenefits -> 返回 ACTIVE + thirdCardNo +4. App用 thirdCardNo 初始化 SDK -> 成功 +5. App获取播放地址 -> 成功 +6. App播放视频 -> 正常播放 +``` + +### 场景2: 异步激活等待 + +``` +前置: 第三方刚调完 consume,异步激活尚未完成 + +1. App调 init -> 返回 sign +2. App调 checkBenefits -> 返回 UNBOUND +3. App轮询 (每2秒) +4. N秒后 checkBenefits -> 返回 ACTIVE + thirdCardNo +5. App用 thirdCardNo 初始化 SDK -> 成功 +``` + +### 场景3: 设备未注册 + +``` +前置: 第三方未调 consume + +1. App调 init -> 返回 400 "设备未注册" +2. App显示错误提示 +``` + +### 场景4: 天卡过期 + +``` +前置: 天卡已过期 + +1. App调 init -> 返回 sign +2. App调 checkBenefits -> 返回 UNBOUND +3. App显示"天卡已过期,请联系第三方续费" +``` + +## 9. 本次测试凭证 + +``` +后端地址: http://58.220.83.58:8084 +设备标识: mac=AA:BB:CC:DD:EE:01, deviceSn=TEST-SN-001, deviceUid=TEST-UID-001 +deviceId: 9 +sign: (通过 init 动态获取) +thirdCardNo: DKMMqjJKgpdwUahip5l4hGzjS +expireAt: 2026-07-24 19:33:14 +``` + +## 10. 关键注意事项 + +1. **不传 activationCode**:代销模式 init 不传 activationCode,后端按设备标识查找 +2. **设备标识一致性**:consume 和 init 必须用相同的设备标识,否则找不到设备 +3. **thirdCardNo 不暴露给第三方**:thirdCardNo 由 App 通过 checkBenefits 获取,不经过 EPG +4. **sign 24小时有效**:长时间运行需定期心跳续期 +5. **SDK utype**:uid 为 MAC 格式用 "3",非 MAC 格式用 "2"(thirdCardNo 通常是非 MAC 格式 -> "2") diff --git a/第三方合作伙伴接口流程.md b/第三方合作伙伴接口流程.md new file mode 100644 index 0000000..ccb240f --- /dev/null +++ b/第三方合作伙伴接口流程.md @@ -0,0 +1,194 @@ +# 第三方合作伙伴接口完整流程 + +## 阶段一:管理后台准备(平台方操作) + +``` +1. 创建合作伙伴 + POST /manage/partner/save (JWT鉴权) + -> 自动生成 partnerId / appKey / appSecret,交给第三方 + +2. 采购天卡到合作伙伴池 + POST /manage/partner-card/purchase?partnerId=xxx&quantity=N (JWT鉴权) + -> 平台天卡池 UNUSED -> RESERVED (FOR UPDATE SKIP LOCKED) + -> 写入 partner 天卡池 status=AVAILABLE + +3. 配置内容包授权范围(可选) + POST /manage/content-package/... (JWT鉴权) + -> 三级授权:CONTENT(单部) > CATALOG(栏目) > FULL(全量) +``` + +## 阶段二:第三方EPG界面展示与支付(第三方完成) + +``` +第三方自行开发EPG界面和支付流程 + +可选:第三方调媒资接口展示内容 + GET /openapi/partner/media/catalog-tree (appKey签名鉴权) + GET /openapi/partner/media/list?catalogid=xxx + GET /openapi/partner/media/search?keyword=xxx + -> 按内容包授权范围过滤返回 + +支付完成后进入阶段三 +``` + +## 阶段三:第三方调消耗接口发卡(核心) + +``` +POST /openapi/partner/card/consume (appKey签名鉴权) +Headers: appKey / timestamp / nonce / sign +Body: { mac, wifiMac, deviceUid, deviceSn, channelOrgId } + (mac/wifiMac/deviceUid/deviceSn 至少传一个) + +处理流程: + ① PartnerSignFilter 校验签名: MD5(appKey+timestamp+nonce+appSecret).toUpperCase() + - timestamp 5分钟有效期 + nonce 5分钟防重放(Redis) + + ② Redis 防重锁: daycard:partner:consume:{设备标识}, TTL 30s + + ③ 查找/创建设备 (优先级: mac > wifiMac > deviceUid > deviceSn) + + ④ 幂等检查: 设备已有有效天卡(status=USED且未过期) -> 直接返回, 不重复消耗 + + ⑤ FOR UPDATE SKIP LOCKED 取partner池AVAILABLE天卡 (防并发超卖) + + ⑥ 绑定天卡到设备: + - day_card_pool: RESERVED -> USED, device_id, activate_status=PENDING, expire_at=24h兜底 + - partner_card_pool: AVAILABLE -> CONSUMED + + ⑦ @Transactional 事务保护 ②~⑥ 全部数据库写操作 + + ⑧ 异步激活天卡 (线程池 thirdPartyActivateExecutor) + +返回: { deviceId, expireAt, status:"ACTIVATING" } + thirdCardNo 不暴露给第三方, App 通过 checkBenefits 自行获取 +``` + +## 阶段四:异步激活天卡(调橘视厅,自动完成) + +``` +activateCardAsync(poolId, deviceId): + + ① 激活锁: api:thirdparty:activate:lock:{poolId}, TTL 3600s + 防止同一张卡重复激活 + + ② 获取Token (优先缓存): + - 缓存key: api:thirdparty:activate:{thirdCardNo}, TTL 5分钟 + - 命中 -> 直接用 token + memberCode + - 未命中 -> 调橘视厅免密登录: + POST {baseUrl}/ums_api/api/v1/third/login (RSA加密签名) + 返回 token + memberCode -> 写入缓存 + + ③ 卡密激活: + POST {baseUrl}/ccms_api/hswj/api/card/activate?sign={MD5签名} + - E0000003 + "已激活,重复使用激活码" 视为幂等成功 + + ④ 确定过期时间: + 优先 activateCard返回的expireTime -> 其次queryVipExpireTime -> 兜底24h + + ⑤ 更新 day_card_pool: activate_status=SUCCESS, expire_at=真实过期时间 + + ⑥ finally 释放激活锁 + + 异常: activate_status=FAILED + 错误信息 +``` + +## 阶段五:EPG传参启动App + +``` +EPG通过app传参启动App, App使用同一设备标识初始化: + +POST /api/jst/init (无需sign) +Body: { mac, wifiMac, deviceSn, deviceUid, ...(设备信息) } + 不传 activationCode -> 代销模式 doInitWithoutActivationCode + + ① 按优先级查找已注册设备: mac > wifiMac > deviceSn > deviceUid + (设备由阶段三 consume 接口创建) + ② 找不到 -> "设备未注册,请先通过合作伙伴天卡消耗接口激活设备" + ③ 找到 -> 更新心跳, 生成 sign + sign = Base64URL(deviceId:timestamp:orgCode:HMAC-SHA256), TTL 24h + +返回: { device: {deviceId, roomNo}, sign } +``` + +## 阶段六:App查询权益拿播放凭证 + +``` +POST /api/jst/checkBenefits (需sign) + + ① ApiDeviceSignFilter 校验sign, 解析deviceId + ② 查询 day_card_pool: device_id + status=USED + expire_at > NOW() + +激活完成返回: + { status:"ACTIVE", thirdCardNo:"xxx", cardNo:"xxx", expireAt:"..." } + -> thirdCardNo 即播放凭证 + +激活未完成返回: + { status:"UNBOUND" } + -> App 轮询直到 status 变为 ACTIVE + +辅助接口: + POST /api/jst/heartBeat -> 续期sign + POST /api/jst/refreshSign -> 换新sign +``` + +## 阶段七:App播放 + +``` +App使用 thirdCardNo 作为播放凭证 +-> 请求橘视厅SDK初始化 +-> 换取播放地址 +-> 播放内容 +``` + +## 完整时序图 + +``` +管理后台 第三方EPG 本系统 橘视厅 App + | | | | | + |--采购天卡------->| | | | + | | | | | + | [用户浏览付费] | | | + | |--consume-------->| | | + | | |--取卡+绑卡 | | + | | |--异步激活-------->| | + | |<--deviceId-------| | | + | | expireAt | | | + | | ACTIVATING | | | + | | |<--激活成功-------| | + | | | | | + | |--传参启动App---->| | | + | | (设备标识) | | | + | | | |<--init--------| + | | | | (免激活码) | + | | | |--sign-------->| + | | | | | + | | | |<--checkBenefits| + | | | | (携带sign) | + | | | |--thirdCardNo->| + | | | | | + | | | | [SDK初始化] + | | | | [播放] | +``` + +## 关键设计说明 + +### 安全设计 +- **thirdCardNo 不暴露给第三方**:播放凭证仅通过 App 的 `init -> checkBenefits` 链路获取,第三方无法绕过 App 直接播放 +- **签名鉴权**:第三方接口通过 `MD5(appKey+timestamp+nonce+appSecret)` 签名,timestamp 5分钟有效期 + nonce 防重放 +- **设备 sign 鉴权**:App 接口通过 HMAC-SHA256 sign 鉴权,24小时有效,Redis 保持最新 sign + +### 并发控制 +- **consume 防重锁**:Redis `daycard:partner:consume:{设备标识}`,TTL 30s,防止并发重复消耗 +- **取卡行级锁**:`FOR UPDATE SKIP LOCKED`,防止并发超卖 +- **激活锁**:Redis `api:thirdparty:activate:lock:{poolId}`,TTL 3600s,防止重复激活 + +### 幂等设计 +- **consume 幂等**:设备已有有效天卡时直接返回,不重复消耗 +- **卡密激活幂等**:橘视厅返回 E0000003 + "已激活,重复使用激活码" 视为成功 + +### 性能优化 +- **Token 缓存**:Redis `api:thirdparty:activate:{thirdCardNo}`,TTL 5分钟,减少橘视厅登录调用 +- **异步激活**:consume 接口立即返回 ACTIVATING,激活在后台线程池完成 + +### 事务保障 +- consume 方法 `@Transactional(rollbackFor = Exception.class)`,保证设备创建、卡池状态更新、partner池更新的原子性