From fc36d1b17ad05fdd0be33dd3a519d7b7f3560487 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Wed, 11 Nov 2026 19:39:10 +0800 Subject: [PATCH] 11 --- AGENTS.md | 51 ++ .../app/20261111_app_version_management.sql | 106 +++ .../20261111_app_version_multi_platform.sql | 78 ++ sql/mysql/app/README.md | 44 + .../20261111_member_push_management.sql | 152 ++++ .../yudao/module/infra/api/file/FileApi.java | 8 + .../module/infra/api/file/FileApiImpl.java | 13 + .../controller/admin/file/FileController.java | 23 +- .../config/FileUploadLimitConfiguration.java | 57 ++ .../file/core/client/FileClient.java | 8 + .../file/core/client/db/DBFileClient.java | 49 ++ .../file/core/client/ftp/FtpFileClient.java | 38 +- .../core/client/local/LocalFileClient.java | 24 + .../file/core/client/s3/S3FileClient.java | 28 + .../file/core/client/sftp/SftpFileClient.java | 34 +- .../infra/service/file/FileService.java | 14 + .../infra/service/file/FileServiceImpl.java | 64 ++ .../file/FileControllerStreamingTest.java | 54 ++ .../framework/file/FileStreamingTest.java | 118 +++ .../FileUploadLimitConfigurationTest.java | 59 ++ .../file/FileConfigServiceImplTest.java | 2 +- .../vo/record/BrokerageRecordPageReqVO.java | 3 + .../withdraw/BrokerageWithdrawPageReqVO.java | 3 + .../brokerage/AppBrokerageUserController.java | 24 +- .../record/AppBrokerageRecordPageReqVO.java | 3 + .../vo/record/AppBrokerageRecordRespVO.java | 12 + ...AppBrokerageUserChildSummaryPageReqVO.java | 17 +- .../AppBrokerageUserChildSummaryRespVO.java | 9 +- .../user/AppBrokerageUserMySummaryRespVO.java | 4 + .../brokerage/BrokerageRecordMapper.java | 5 + .../mysql/brokerage/BrokerageUserMapper.java | 4 +- .../brokerage/BrokerageWithdrawMapper.java | 5 + .../brokerage/BrokerageRecordServiceImpl.java | 16 +- .../brokerage/BrokerageUserServiceImpl.java | 5 +- .../BrokerageWithdrawServiceImpl.java | 16 +- .../mapper/brokerage/BrokerageUserMapper.xml | 39 +- .../AppBrokerageIncomeControllerTest.java | 227 +++++ .../brokerage/AppBrokerageTeamQueryTest.java | 83 ++ .../AppBrokerageTeamSortingTest.java | 255 ++++++ .../brokerage/BrokerageIncomeQueryTest.java | 30 + .../BrokerageUserNicknameQueryTest.java | 88 ++ .../BrokerageUserRelationshipTest.java | 37 +- .../module/member/enums/MemberErrorCode.java | 19 + .../module/member/client/service/ChatApi.java | 2 + .../member/client/service/ChatApiImpl.java | 21 + .../ChatPrivatePushContextRespVO.java | 26 + .../appversion/AppVersionController.java | 131 +++ .../vo/AppVersionMemberPageReqVO.java | 15 + .../appversion/vo/AppVersionMemberRespVO.java | 25 + .../vo/AppVersionPackageRespVO.java | 53 ++ .../appversion/vo/AppVersionPageReqVO.java | 25 + .../admin/appversion/vo/AppVersionRespVO.java | 47 ++ .../appversion/vo/AppVersionSaveReqVO.java | 34 + .../admin/push/MemberPushController.java | 59 ++ .../admin/push/vo/MemberPushPageReqVO.java | 17 + .../push/vo/MemberPushRecordPageReqVO.java | 14 + .../admin/push/vo/MemberPushSendReqVO.java | 18 + .../admin/push/vo/MemberPushUserRespVO.java | 12 + .../ImPushCallbackController.java | 78 ++ .../appversion/AppAppVersionController.java | 44 + .../appversion/vo/AppVersionCheckReqVO.java | 27 + .../appversion/vo/AppVersionUpdateRespVO.java | 21 + .../app/chat/AppChatController.java | 6 - .../app/push/vo/AppPushBindReqVO.java | 9 + .../dataobject/appversion/AppVersionDO.java | 66 ++ .../appversion/AppVersionMemberDO.java | 24 + .../appversion/AppVersionPackageDO.java | 57 ++ .../dataobject/push/MemberPushDeviceDO.java | 5 + .../dataobject/push/MemberPushRecordDO.java | 35 + .../dal/dataobject/push/MemberPushTaskDO.java | 43 + .../mysql/appversion/AppVersionMapper.java | 39 + .../appversion/AppVersionMemberMapper.java | 30 + .../appversion/AppVersionPackageMapper.java | 10 + .../mysql/push/MemberPushDeviceMapper.java | 23 +- .../mysql/push/MemberPushRecordMapper.java | 18 + .../dal/mysql/push/MemberPushTaskMapper.java | 32 + .../appversion/AndroidManifestReader.java | 445 ++++++++++ .../appversion/AppVersionPackageParser.java | 136 +++ .../service/appversion/AppVersionService.java | 36 + .../appversion/AppVersionServiceImpl.java | 460 ++++++++++ .../service/push/ImPrivatePushEvent.java | 22 + .../member/service/push/JPushGateway.java | 133 +++ .../push/MemberPushDeviceServiceImpl.java | 54 +- .../service/push/MemberPushService.java | 12 - .../service/push/MemberPushServiceImpl.java | 118 --- .../service/push/MemberPushTaskService.java | 623 ++++++++++++++ .../service/ChatApiImplPushContextTest.java | 80 ++ .../MemberPushControllerSecurityTest.java | 95 +++ .../ImPushCallbackControllerTest.java | 96 +++ .../AppVersionPackageParserTest.java | 301 +++++++ .../AppVersionPublicControllerTest.java | 33 + .../appversion/AppVersionSchemaTest.java | 135 +++ .../appversion/AppVersionServiceImplTest.java | 324 +++++++ .../member/service/push/JPushGatewayTest.java | 109 +++ .../push/MemberPushTaskServiceMySqlTest.java | 796 ++++++++++++++++++ .../src/main/resources/application.yaml | 11 +- 96 files changed, 6804 insertions(+), 209 deletions(-) create mode 100644 AGENTS.md create mode 100644 sql/mysql/app/20261111_app_version_management.sql create mode 100644 sql/mysql/app/20261111_app_version_multi_platform.sql create mode 100644 sql/mysql/app/README.md create mode 100644 sql/mysql/member/20261111_member_push_management.sql create mode 100644 yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/FileUploadLimitConfiguration.java create mode 100644 yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileControllerStreamingTest.java create mode 100644 yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/FileStreamingTest.java create mode 100644 yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/FileUploadLimitConfigurationTest.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/AppBrokerageIncomeControllerTest.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/AppBrokerageTeamQueryTest.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/AppBrokerageTeamSortingTest.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserNicknameQueryTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivatePushContextRespVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/AppVersionController.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionMemberPageReqVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionMemberRespVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionPackageRespVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionPageReqVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionRespVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionSaveReqVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/MemberPushController.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/vo/MemberPushPageReqVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/vo/MemberPushRecordPageReqVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/vo/MemberPushSendReqVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/vo/MemberPushUserRespVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/thirdcallback/ImPushCallbackController.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/appversion/AppAppVersionController.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/appversion/vo/AppVersionCheckReqVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/appversion/vo/AppVersionUpdateRespVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/appversion/AppVersionDO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/appversion/AppVersionMemberDO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/appversion/AppVersionPackageDO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/push/MemberPushRecordDO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/push/MemberPushTaskDO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/appversion/AppVersionMapper.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/appversion/AppVersionMemberMapper.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/appversion/AppVersionPackageMapper.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/push/MemberPushRecordMapper.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/push/MemberPushTaskMapper.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/appversion/AndroidManifestReader.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionPackageParser.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionService.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionServiceImpl.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/ImPrivatePushEvent.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/JPushGateway.java delete mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushService.java delete mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushServiceImpl.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushTaskService.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/ChatApiImplPushContextTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/push/MemberPushControllerSecurityTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/thirdcallback/ImPushCallbackControllerTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionPackageParserTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionPublicControllerTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionSchemaTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionServiceImplTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/push/JPushGatewayTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/push/MemberPushTaskServiceMySqlTest.java diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9090923 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# 项目协作与记忆 + +本文件记录用户已确认的需求和有来源的项目事实。业务数据与运行配置可能变化,后续任务应核对当前源码、运行环境及数据库。 + +## 项目入口 + +- 当前目录为 Java 后端,采用 Maven 多模块,服务入口为 `yudao-server`。 +- PC 管理端位于相邻的 `Friendship-and-chat-pc`。会员列表入口为 `src/views/member/user/index.vue`,会员详情为 `src/views/member/user/detail/index.vue`。 +- 当前 H5 源码位于相邻的 `Friendship-and-chat-uniapp`。福利中心为 `src/pages/wechat-tabbar/index.vue`,我的团队为 `src/pages/wechat/pages/inviter/index.vue`。 +- 分销业务主要位于 `yudao-module-mall/yudao-module-trade-biz`。会员管理通过 `trade-api` 复用分销查询。 +- 数据库连接与实际启用的 profile 从项目配置和运行进程核对。H5 的开发 API 地址可能使用隧道,需确认其最终服务。 + +## 已确认的分销口径 + +- `member_user.id` 是内部会员编号,`user_code` 是 App 展示 ID。关联和贡献查询使用内部编号。 +- `trade_brokerage_record.user_id` 是佣金收益人,`source_user_id` 是产生佣金的来源会员,`source_user_level` 保存当时的推广层级。 +- 管理端“分销收益”累计已结算的有效推广佣金和会员佣金。提现操作保留累计收益金额,待结算金额单独展示。 +- “为当前会员贡献”按该来源会员本人给详情根会员产生的佣金计算。多层展开时 `contributionUserId` 固定为详情根会员编号。 +- 累计收益统一复用 `BrokerageIncomeSql` 的有效佣金条件。当前条件包含类型 1、2,正金额,以及标题和说明中的提现文本过滤。 +- 状态 0 为待结算,1 为已结算,2 为已取消。历史数据曾使用不同的业务类型映射,核账时同时检查金额方向、标题、说明和时间。 +- `trade_brokerage_user.brokerage_price` 是独立维护的可用佣金余额,`frozen_price` 是冻结余额。余额与累计收益分别核账。 +- `trade_brokerage_record.total_price` 是构建流水时读取的余额快照,应结合实际写入顺序核对其时点。 +- 后台累计金额使用 `Long`,单位为分。前端需支持超出 JavaScript 安全整数后返回的字符串金额,使用 `brokeragePriceToYuan` 精确显示。 + +## 团队与页面行为 + +- 一级关系来自 `trade_brokerage_user.bind_user_id`,二级为一级会员的直属下级。 +- 后台关系树每个分支每次加载 20 人,接口的 `total` 表示该分支完整人数。已加载节点数随展开及“加载更多”变化。 +- H5 团队汇总、H5 团队分页和后台关系树应分别核对。资料缺失处理、昵称搜索、排序和分页可能影响当前展示的列表。 +- 会员列表的分销收益金额可点击,进入对应会员的 `?tab=brokerage` 详情页。详情使用 `onActivated` 处理缓存页再次进入。 +- 金额区间筛选在数据库分页前完成,列表和导出共用 `MemberUserMapper.buildUserQuery`。 +- 新查询复用现有租户组件,并核对实际开关。相关子查询使用 `(SELECT COALESCE(SUM(...), 0) FROM ...)` 结构,已有测试验证该结构可被租户插件正确处理。 + +## 协作与验证 + +- 数据库操作遵循用户当次授权。本次用户 300 的金额与团队排查采用只读事务。 +- 从配置读取连接凭据时避免在输出、文档和记忆中保存密码或令牌。 +- 工作区经常存在并行任务,编辑前查看当前差异并保留其他任务的修改。 +- Maven 验证可使用 `-Dflatten.skip=true`,避免构建过程改写项目 POM。 +- 现有回归入口包括 `BrokerageIncomeQueryTest`、`MemberUserMapperTest`、`MemberUserBrokerageControllerTest`,以及 PC 端的 `tests/member-user-brokerage.test.cjs` 和 `tests/brokerage-record-list.test.cjs`。 +- 测试、构建、页面核验及部署分别记录实际结果。数据库历史余额的来源需要对应流水、迁移记录或备份证据。 + +## 2026-11-11 用户 300 的只读对账快照 + +- 运行 `dev` 数据库中,会员内部编号 300,展示 ID 999827。H5 福利中心实际展示同一展示 ID。 +- 可用佣金为 20841 分,冻结为 0。现存佣金流水含已删除记录一起核查后,只有一笔 3510 分的已结算会员佣金,流水编号 15,来源会员 465,创建于 2026-11-02 18:39:32。提现表无该会员记录。 +- 流水 15 的 `total_price` 为 17331 分,结合转换器及先写流水后更新余额的顺序,表示这笔佣金写入前的余额。17331 + 3510 = 20841,历史底数的原始入账流水尚未在当前库中找到。 +- 会员资料创建于 2026-11-01,`tenant_id=0`。分销余额及绑定关系创建于 2025-09-30,`tenant_id=1`。本次运行配置关闭租户隔离,历史数据归属及导入过程需要单独核实。 +- 该会员一级 43 人、二级 13 人,全部会员资料有效。后台初次加载一级 20 人,加载完整后为 43 人。13 个二级会员均挂在一级会员 374 下,该一级节点按后台 ID 倒序位于第 30 位,需要加载更多后展开。 +- H5 福利中心“累计收益”当时绑定提现合计 `withdrawPrice`。团队页的金额当时直接显示分值为元,单数字段读取 `orderCount`,接口实际返回 `brokerageOrderCount`。团队行金额查询当时仅统计该下级自身的已结算订单佣金。 +- 上述为带日期的排查事实,后续修复与数据变化以当前实现及重新核账为准。 diff --git a/sql/mysql/app/20261111_app_version_management.sql b/sql/mysql/app/20261111_app_version_management.sql new file mode 100644 index 0000000..677e358 --- /dev/null +++ b/sql/mysql/app/20261111_app_version_management.sql @@ -0,0 +1,106 @@ +-- App 可信安装包、版本发布及逐版本灰测名单。适用于 MySQL 5.7/8.0。 +-- 选择目标业务库后执行,先备份。此脚本只创建新表与菜单,不发布版本、不改写会员。 +-- 重复执行安全。部署顺序为执行脚本、发布 Java、分配菜单权限、发布 PC、重新打原生基座。 + +CREATE TABLE IF NOT EXISTS `member_app_version_package` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '可信包编号', + `app_id` varchar(100) NOT NULL COMMENT 'uni-app AppID', + `platform` varchar(64) NOT NULL COMMENT '规范CSV目标平台,android/ios/harmony', + `package_type` varchar(8) NOT NULL COMMENT 'apk/wgt', + `file_name` varchar(255) NOT NULL COMMENT '原始文件名', + `file_size` bigint NOT NULL COMMENT '文件大小,字节', + `sha256` char(64) NOT NULL COMMENT '真实文件SHA-256', + `md5` char(32) NOT NULL COMMENT '原生运行时文件校验MD5', + `download_url` varchar(2048) NOT NULL COMMENT '已有存储生成的地址', + `version_code` int NOT NULL COMMENT 'APK原生版本码或WGT资源版本码', + `version_name` varchar(100) NOT NULL COMMENT '包清单版本名', + `native_version_code` int DEFAULT NULL COMMENT 'APK原生基座版本码', + `resource_version_code` int NOT NULL COMMENT '包内资源版本码', + `package_name` varchar(200) DEFAULT NULL COMMENT 'APK原生包名', + `creator` varchar(64) DEFAULT '', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updater` varchar(64) DEFAULT '', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted` bit(1) NOT NULL DEFAULT b'0', + `tenant_id` bigint NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_app_package_tenant` (`tenant_id`, `app_id`, `platform`, `package_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='App可信安装包'; + +CREATE TABLE IF NOT EXISTS `member_app_version` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '版本发布编号', + `package_id` bigint NOT NULL COMMENT '可信包编号', + `app_id` varchar(100) NOT NULL COMMENT 'uni-app AppID快照', + `platform` varchar(64) NOT NULL COMMENT '规范CSV目标平台,android/ios/harmony', + `package_type` varchar(8) NOT NULL COMMENT 'apk/wgt', + `version_code` int NOT NULL COMMENT '按包类型比较的版本码', + `version_name` varchar(100) NOT NULL COMMENT '展示版本名', + `native_version_code` int DEFAULT NULL COMMENT 'APK原生基座版本码', + `resource_version_code` int NOT NULL COMMENT '目标资源版本码', + `package_name` varchar(200) DEFAULT NULL COMMENT 'APK原生包名', + `update_content` text NOT NULL COMMENT '纯文本更新说明', + `force_update` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否强制更新', + `release_scope` varchar(16) NOT NULL DEFAULT 'all' COMMENT 'all/members', + `status` varchar(16) NOT NULL DEFAULT 'draft' COMMENT 'draft/published/offline', + `min_native_version_code` int DEFAULT NULL COMMENT 'WGT最低兼容基座,包含边界', + `max_native_version_code` int DEFAULT NULL COMMENT 'WGT最高兼容基座,NULL无上限', + `publish_time` datetime DEFAULT NULL COMMENT '最近发布时间', + `creator` varchar(64) DEFAULT '', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updater` varchar(64) DEFAULT '', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted` bit(1) NOT NULL DEFAULT b'0', + `tenant_id` bigint NOT NULL DEFAULT 0, + `active_unique_flag` tinyint GENERATED ALWAYS AS (CASE WHEN `deleted` = 0 THEN 1 ELSE NULL END) STORED COMMENT '有效记录唯一标记', + `android_active_flag` tinyint GENERATED ALWAYS AS (CASE WHEN `deleted` = 0 AND LOCATE(',android,', CONCAT(',', `platform`, ',')) > 0 THEN 1 ELSE NULL END) STORED COMMENT 'android有效目标唯一标记', + `ios_active_flag` tinyint GENERATED ALWAYS AS (CASE WHEN `deleted` = 0 AND LOCATE(',ios,', CONCAT(',', `platform`, ',')) > 0 THEN 1 ELSE NULL END) STORED COMMENT 'ios有效目标唯一标记', + `harmony_active_flag` tinyint GENERATED ALWAYS AS (CASE WHEN `deleted` = 0 AND LOCATE(',harmony,', CONCAT(',', `platform`, ',')) > 0 THEN 1 ELSE NULL END) STORED COMMENT 'harmony有效目标唯一标记', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_app_version_active` (`tenant_id`,`app_id`,`platform`,`package_type`,`version_code`,`active_unique_flag`), + UNIQUE KEY `uk_app_version_android_active` (`tenant_id`,`app_id`,`package_type`,`version_code`,`android_active_flag`), + UNIQUE KEY `uk_app_version_ios_active` (`tenant_id`,`app_id`,`package_type`,`version_code`,`ios_active_flag`), + UNIQUE KEY `uk_app_version_harmony_active` (`tenant_id`,`app_id`,`package_type`,`version_code`,`harmony_active_flag`), + KEY `idx_app_version_check` (`tenant_id`,`app_id`,`platform`,`status`,`resource_version_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='App版本发布'; + +CREATE TABLE IF NOT EXISTS `member_app_version_member` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `version_id` bigint NOT NULL COMMENT '版本发布编号', + `member_id` bigint NOT NULL COMMENT '真实会员主键', + `creator` varchar(64) DEFAULT '', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updater` varchar(64) DEFAULT '', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted` bit(1) NOT NULL DEFAULT b'0', + `tenant_id` bigint NOT NULL DEFAULT 0, + `active_unique_flag` tinyint GENERATED ALWAYS AS (CASE WHEN `deleted` = 0 THEN 1 ELSE NULL END) STORED, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_app_version_member_active` (`tenant_id`,`version_id`,`member_id`,`active_unique_flag`), + KEY `idx_app_version_member_lookup` (`tenant_id`,`member_id`,`version_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='逐版本灰测会员'; + +-- 优先复用既有 app/version/index 菜单及其父目录,保留已分配角色关系。 +SET @app_version_menu_id = (SELECT MIN(id) FROM system_menu WHERE component = 'app/version/index' AND deleted = 0); +SET @app_parent_id = (SELECT parent_id FROM system_menu WHERE id = @app_version_menu_id); +SET @app_parent_id = COALESCE(@app_parent_id, (SELECT MIN(id) FROM system_menu WHERE type = 1 AND path IN ('app', '/app') AND deleted = 0)); +INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component, status, visible, keep_alive, always_show, creator, updater, deleted) +SELECT '应用管理', '', 1, 30, 0, 'app', 'ep:iphone', NULL, 0, b'1', b'1', b'1', '1', '1', b'0' +WHERE @app_parent_id IS NULL; +SET @app_parent_id = COALESCE(@app_parent_id, (SELECT MIN(id) FROM system_menu WHERE type = 1 AND path IN ('app','/app') AND deleted = 0)); +INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, updater, deleted) +SELECT '版本管理', 'app:version:query', 2, 10, @app_parent_id, 'version', 'ep:upload-filled', 'app/version/index', 'AppVersion', 0, b'1', b'1', b'1', '1', '1', b'0' +WHERE @app_version_menu_id IS NULL; +SET @app_version_menu_id = (SELECT MIN(id) FROM system_menu WHERE component = 'app/version/index' AND deleted = 0); +UPDATE system_menu SET permission = 'app:version:query', component_name = 'AppVersion', update_time = CURRENT_TIMESTAMP +WHERE id = @app_version_menu_id; + +INSERT INTO system_menu (name, permission, type, sort, parent_id, path, status, visible, keep_alive, always_show, creator, updater, deleted) +SELECT buttons.name, buttons.permission, 3, buttons.sort, @app_version_menu_id, '', 0, b'1', b'1', b'1', '1', '1', b'0' +FROM ( + SELECT '版本查询' name, 'app:version:query' permission, 1 sort + UNION ALL SELECT '版本新增', 'app:version:create', 2 + UNION ALL SELECT '版本编辑', 'app:version:update', 3 + UNION ALL SELECT '版本删除', 'app:version:delete', 4 + UNION ALL SELECT '版本发布与下架', 'app:version:publish', 5 +) buttons +WHERE NOT EXISTS (SELECT 1 FROM system_menu current_menu WHERE current_menu.parent_id = @app_version_menu_id AND current_menu.permission = buttons.permission AND current_menu.deleted = 0); diff --git a/sql/mysql/app/20261111_app_version_multi_platform.sql b/sql/mysql/app/20261111_app_version_multi_platform.sql new file mode 100644 index 0000000..3df7439 --- /dev/null +++ b/sql/mysql/app/20261111_app_version_multi_platform.sql @@ -0,0 +1,78 @@ +-- WGT 同一发布记录支持多平台。适用于已执行版本管理建表脚本的 MySQL 5.7/8.0。 +-- 选择目标业务库并备份后执行,可重复运行。业务字段原单平台值无需改写。 +-- 平台字段保存规范 CSV,三个生成有效标记分别约束平台重叠,逻辑删除历史不参与唯一性。 +-- 如既有数据已经存在同版本、重叠平台的记录,索引创建会报告冲突,处理对应记录后可重跑。 + +SET @app_platform_sql = IF( + (SELECT CHARACTER_MAXIMUM_LENGTH FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'member_app_version_package' AND COLUMN_NAME = 'platform') < 64, + 'ALTER TABLE `member_app_version_package` MODIFY COLUMN `platform` varchar(64) NOT NULL COMMENT ''规范CSV目标平台,android/ios/harmony''', + 'SELECT ''member_app_version_package.platform already supports multiple targets'' AS migration_step' +); +PREPARE app_platform_stmt FROM @app_platform_sql; +EXECUTE app_platform_stmt; +DEALLOCATE PREPARE app_platform_stmt; + +SET @app_platform_sql = IF( + (SELECT CHARACTER_MAXIMUM_LENGTH FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'member_app_version' AND COLUMN_NAME = 'platform') < 64, + 'ALTER TABLE `member_app_version` MODIFY COLUMN `platform` varchar(64) NOT NULL COMMENT ''规范CSV目标平台,android/ios/harmony''', + 'SELECT ''member_app_version.platform already supports multiple targets'' AS migration_step' +); +PREPARE app_platform_stmt FROM @app_platform_sql; +EXECUTE app_platform_stmt; +DEALLOCATE PREPARE app_platform_stmt; + +SET @app_platform_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'member_app_version' AND COLUMN_NAME = 'android_active_flag') = 0, + 'ALTER TABLE `member_app_version` ADD COLUMN `android_active_flag` tinyint GENERATED ALWAYS AS (CASE WHEN `deleted` = 0 AND LOCATE('',android,'', CONCAT('','', `platform`, '','')) > 0 THEN 1 ELSE NULL END) STORED COMMENT ''android有效目标唯一标记''', + 'SELECT ''android_active_flag already exists'' AS migration_step' +); +PREPARE app_platform_stmt FROM @app_platform_sql; +EXECUTE app_platform_stmt; +DEALLOCATE PREPARE app_platform_stmt; + +SET @app_platform_sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'member_app_version' AND INDEX_NAME = 'uk_app_version_android_active') = 0, + 'ALTER TABLE `member_app_version` ADD UNIQUE KEY `uk_app_version_android_active` (`tenant_id`,`app_id`,`package_type`,`version_code`,`android_active_flag`)', + 'SELECT ''uk_app_version_android_active already exists'' AS migration_step' +); +PREPARE app_platform_stmt FROM @app_platform_sql; +EXECUTE app_platform_stmt; +DEALLOCATE PREPARE app_platform_stmt; + +SET @app_platform_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'member_app_version' AND COLUMN_NAME = 'ios_active_flag') = 0, + 'ALTER TABLE `member_app_version` ADD COLUMN `ios_active_flag` tinyint GENERATED ALWAYS AS (CASE WHEN `deleted` = 0 AND LOCATE('',ios,'', CONCAT('','', `platform`, '','')) > 0 THEN 1 ELSE NULL END) STORED COMMENT ''ios有效目标唯一标记''', + 'SELECT ''ios_active_flag already exists'' AS migration_step' +); +PREPARE app_platform_stmt FROM @app_platform_sql; +EXECUTE app_platform_stmt; +DEALLOCATE PREPARE app_platform_stmt; + +SET @app_platform_sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'member_app_version' AND INDEX_NAME = 'uk_app_version_ios_active') = 0, + 'ALTER TABLE `member_app_version` ADD UNIQUE KEY `uk_app_version_ios_active` (`tenant_id`,`app_id`,`package_type`,`version_code`,`ios_active_flag`)', + 'SELECT ''uk_app_version_ios_active already exists'' AS migration_step' +); +PREPARE app_platform_stmt FROM @app_platform_sql; +EXECUTE app_platform_stmt; +DEALLOCATE PREPARE app_platform_stmt; + +SET @app_platform_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'member_app_version' AND COLUMN_NAME = 'harmony_active_flag') = 0, + 'ALTER TABLE `member_app_version` ADD COLUMN `harmony_active_flag` tinyint GENERATED ALWAYS AS (CASE WHEN `deleted` = 0 AND LOCATE('',harmony,'', CONCAT('','', `platform`, '','')) > 0 THEN 1 ELSE NULL END) STORED COMMENT ''harmony有效目标唯一标记''', + 'SELECT ''harmony_active_flag already exists'' AS migration_step' +); +PREPARE app_platform_stmt FROM @app_platform_sql; +EXECUTE app_platform_stmt; +DEALLOCATE PREPARE app_platform_stmt; + +SET @app_platform_sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'member_app_version' AND INDEX_NAME = 'uk_app_version_harmony_active') = 0, + 'ALTER TABLE `member_app_version` ADD UNIQUE KEY `uk_app_version_harmony_active` (`tenant_id`,`app_id`,`package_type`,`version_code`,`harmony_active_flag`)', + 'SELECT ''uk_app_version_harmony_active already exists'' AS migration_step' +); +PREPARE app_platform_stmt FROM @app_platform_sql; +EXECUTE app_platform_stmt; +DEALLOCATE PREPARE app_platform_stmt; + +SHOW INDEX FROM `member_app_version` WHERE Key_name IN ('uk_app_version_android_active', 'uk_app_version_ios_active', 'uk_app_version_harmony_active'); diff --git a/sql/mysql/app/README.md b/sql/mysql/app/README.md new file mode 100644 index 0000000..d34c7bf --- /dev/null +++ b/sql/mysql/app/README.md @@ -0,0 +1,44 @@ +# App 版本管理上线步骤 + +## 数据库与后台 + +1. 备份目标业务库,选择该库后执行同目录 `20261111_app_version_management.sql`。脚本创建可信包、发布记录、逐版本灰测名单三张表,并复用或创建版本管理菜单。已执行过旧版建表脚本的环境另执行 `20261111_app_version_multi_platform.sql`,扩展平台字段并增加按平台唯一索引。 +2. 发布包含本次改动的 Java 服务。默认 AppID 为 `__UNI__0A84F60`,如需覆盖,设置 `yudao.member.app-version.app-id`。 +3. 管理员在既有角色管理和租户套餐中,按职责分配版本页面及查询、新增、编辑、删除、发布五项权限,重新登录后台刷新权限。超级管理员使用现有权限机制访问。 +4. 发布 PC 管理端,通过版本页上传包并保存草稿。上传会识别包的 AppID、版本与包类型,版本元数据由服务端产生。WGT 默认勾选 Android 和 iOS,发布时可调整目标。 + +## 大包文件存储 + +- OSS/S3、本地、FTP、SFTP 使用文件或输出流传输,专用版本包接口允许最大 512MB。普通上传和导入限制为单文件 16MB、总量 32MB。 +- 数据库文件存储使用 MEDIUMBLOB,最大文件为 16,777,215 字节。大 APK 上传时先把默认文件存储设置为 OSS/S3、本地、FTP 或 SFTP。 +- Servlet 安装包直接落盘,预留临时目录空间。部署代理的请求体限制至少覆盖 520MB,并让前端上传超时和代理等待时间覆盖上传、识别及写入存储的实际耗时。 +- 下载地址来自既有存储配置。生产环境使用设备能够访问的 HTTPS 地址,同时核对域名证书及 iOS/Harmony 原生网络配置。 +- 版本包进入 `version-packages/` 独立路径。通用文件上传、预签名和删除入口保护该路径,避免覆盖已校验文件。 + +## 原生基座与后续发布 + +1. 首次交付发行前,在 `manifest.json` 设置高于已发行版本的新版本码与版本名,再重新打包含 Record、Barcode、更新入口及本次权限声明的原生基座,使用原有发行渠道安装。具体发行号码由既有发行流程确定。保留 Android 包名及签名、uni-app AppID 和 iOS Bundle ID。 +2. Android 的 APK 原生版本码、APK 内资源版本码分别从实际包清单读取。WGT 资源版本从编译后的根 `manifest.json` 读取。 +3. 上传 WGT 时沿用原生基座相同的打包工具链,多选目标平台并填写最低原生基座版本码,最高版本码可空。新增原生插件、SDK 或权限时先发新基座,并让 WGT 的最低基座要求与实际原生能力一致。 +4. 一个 WGT 只需上传一次,可通过同一条发布记录同时更新 Android 和 iOS,使用相同 packageId、下载地址、说明及灰测名单。Android 可发布 APK/WGT,iOS 和 Harmony 可发布 WGT。APK 目标固定为 Android。Harmony 客户端同时具备 `uni.installWgt`、`uni.restart`、`getFileInfo` 能力后启用热更。实人 SDK 目前覆盖 Android/iOS。 +5. 资源版本相同时优先提供兼容的 WGT。WGT 基座不兼容时可以选择 APK,APK 必须提高原生版本并保持资源版本不降级。 + +## 灰测与验收 + +- 每个版本独立选择会员,支持沿用历史名单。灰测用户通过真实 App 登录身份识别,游客只能取得全量版本。 +- 使用同租户的名单内会员、名单外会员和游客分别检查更新,再验证另一租户的隔离行为。 +- 检查草稿、发布、下架、灰测转全量、重复操作以及更高版本发布后的结果。名单和发布状态变化会在下次检查、下载前验证、安装前验证时生效。 +- 验证普通与强制更新、断网、下载失败、校验失败、空间不足、安装权限拒绝及设置页返回重试。下载文件同时核对真实长度和摘要。 +- 检查 Android 8+ 安装来源权限流程,真机验证 APK 安装和 Android/iOS WGT 安装后重启。Harmony 通过实际目标 SDK 和设备验证能力。 +- 下架可以停止后续分发。已安装版本通过更高资源版本的修复包处理。 + +## 本地验证记录 + +- 后端相关回归共 75 项通过,覆盖二进制 AndroidManifest 解析、WGT 编译清单、双摘要、基座兼容、同资源 WGT 优先、资源防降级、名单校验、状态限制、租户 SQL 条件重写、流式存储和普通上传容量保护。多平台回归验证同一发布编号、packageId 和下载地址在 Android/iOS 的检查及安装前验证中可用。 +- 新表有效唯一键通过 H2 MySQL 模式验证多轮名单替换、删除重建、并发目标重叠和租户维度。独立 MySQL 5.7 临时库验证了新建表、原单平台记录升级、升级脚本重复执行、双目标精确查询、同版平台重叠拒绝、不重叠目标及跨租户允许、逻辑删除后重建和字段宽度。生产迁移、OSS 网络链路及真机更新验收需在目标环境完成。 +- APK 原生清单采用有界随机读取,资源引用版本名按资源 ID 定向定位。已覆盖损坏计数、字符串长度与命名空间校验,15 项包解析测试通过独立 64MB 堆内存验证。 +- PC 真实组件通过模拟接口浏览器验证,包含列表、发布抽屉、跨页会员选择、复制、保存、深色和 375px 小窗口。新增文件的类型检查及 ESLint 通过,整仓类型检查遇到 4GB 堆内存耗尽。 +- uni-app 的 App、Harmony 目标资源、H5 和微信小程序编译通过,更新、原生适配、权限、录音和账号会话回归共 40 项通过。目标 TypeScript 文件检查通过,全量类型检查受既有 allowJs 配置与 uview-plus 语法问题阻断。 +- Java 聚合打包使用当前工作区,其中同时存在推送相关改动。发行前按当前工作区整体核对发布内容。 + +当前开发环境未连接 Android 真机或运行中的 iOS 模拟器。安装权限、APK 实际安装、WGT 原生安装与重启、录音、扫码及人脸授权流程均待目标真机验收。Harmony NEXT 已覆盖能力检测分支和目标资源编译,原生 SDK 打包及设备行为需要专项验证。 diff --git a/sql/mysql/member/20261111_member_push_management.sql b/sql/mysql/member/20261111_member_push_management.sql new file mode 100644 index 0000000..79be94a --- /dev/null +++ b/sql/mysql/member/20261111_member_push_management.sql @@ -0,0 +1,152 @@ +-- 可重复执行,兼容已有设备绑定表。任务及设备明细长期保存。 +CREATE TABLE IF NOT EXISTS member_push_device ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + registration_id VARCHAR(64) NOT NULL, + alias VARCHAR(64) NULL, + platform VARCHAR(16) NOT NULL, + provider VARCHAR(16) NOT NULL DEFAULT 'jpush', + app_key VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '', + tenant_id BIGINT NOT NULL DEFAULT 0, + creator VARCHAR(64) NULL DEFAULT '', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NULL DEFAULT '', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted BIT(1) NOT NULL DEFAULT b'0', + PRIMARY KEY (id), + UNIQUE KEY uk_registration_id (registration_id), + KEY idx_member_id (member_id), + KEY idx_app_provider_id (app_key, provider, id, tenant_id), + KEY idx_app_member_provider (app_key, member_id, provider, tenant_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会员推送设备'; + +-- 使用同一个数据库连接顺序执行。历史设备保留未知 AppKey,等待新版客户端重新绑定。 +SET @member_push_schema_ddl = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'member_push_device' AND COLUMN_NAME = 'app_key') = 0, + 'ALTER TABLE member_push_device ADD COLUMN app_key VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '''' AFTER provider', + 'SELECT 1'); +PREPARE member_push_schema_stmt FROM @member_push_schema_ddl; +EXECUTE member_push_schema_stmt; +DEALLOCATE PREPARE member_push_schema_stmt; + +SET @member_push_schema_ddl = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'member_push_device' AND INDEX_NAME = 'idx_app_provider_id') = 0, + 'ALTER TABLE member_push_device ADD INDEX idx_app_provider_id (app_key, provider, id, tenant_id)', + 'SELECT 1'); +PREPARE member_push_schema_stmt FROM @member_push_schema_ddl; +EXECUTE member_push_schema_stmt; +DEALLOCATE PREPARE member_push_schema_stmt; + +SET @member_push_schema_ddl = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'member_push_device' AND INDEX_NAME = 'idx_app_member_provider') = 0, + 'ALTER TABLE member_push_device ADD INDEX idx_app_member_provider (app_key, member_id, provider, tenant_id)', + 'SELECT 1'); +PREPARE member_push_schema_stmt FROM @member_push_schema_ddl; +EXECUTE member_push_schema_stmt; +DEALLOCATE PREPARE member_push_schema_stmt; + +CREATE TABLE IF NOT EXISTS member_push_task ( + id BIGINT NOT NULL AUTO_INCREMENT, + request_key VARCHAR(160) NOT NULL, + request_hash VARCHAR(64) NOT NULL, + source VARCHAR(16) NOT NULL, + target_type VARCHAR(16) NOT NULL, + member_ids MEDIUMTEXT NOT NULL, + operator_id BIGINT NULL, + message_id BIGINT NULL, + sender_id BIGINT NULL, + receiver_id BIGINT NULL, + title VARCHAR(160) NOT NULL, + content VARCHAR(500) NOT NULL, + status VARCHAR(24) NOT NULL, + audience_complete BIT(1) NOT NULL DEFAULT b'0', + audience_cursor BIGINT NOT NULL DEFAULT 0, + snapshot_max_id BIGINT NOT NULL DEFAULT 0, + total_members INT NOT NULL DEFAULT 0, + total_devices INT NOT NULL DEFAULT 0, + submitted_count INT NOT NULL DEFAULT 0, + failed_count INT NOT NULL DEFAULT 0, + skipped_count INT NOT NULL DEFAULT 0, + processed_count INT NOT NULL DEFAULT 0, + last_error VARCHAR(1000) NULL, + attempt_count INT NOT NULL DEFAULT 0, + next_retry_time DATETIME(3) NOT NULL, + expires_at DATETIME(3) NOT NULL, + finish_time DATETIME(3) NULL, + tenant_id BIGINT NOT NULL DEFAULT 0, + creator VARCHAR(64) NULL DEFAULT '', + create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updater VARCHAR(64) NULL DEFAULT '', + update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted BIT(1) NOT NULL DEFAULT b'0', + PRIMARY KEY (id), + UNIQUE KEY uk_tenant_request (tenant_id, request_key), + KEY idx_pending (status, next_retry_time), + KEY idx_tenant_time (tenant_id, create_time), + KEY idx_message (message_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='App推送任务'; + +CREATE TABLE IF NOT EXISTS member_push_record ( + id BIGINT NOT NULL AUTO_INCREMENT, + task_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + nickname VARCHAR(255) NULL, + user_code VARCHAR(64) NULL, + device_id BIGINT NULL, + platform VARCHAR(16) NULL, + registration_id VARCHAR(64) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL, + attempt_count INT NOT NULL DEFAULT 0, + cid VARCHAR(128) NULL, + batch_payload MEDIUMTEXT NULL COMMENT '批次首行保存固定请求体,用于不确定结果恢复', + jpush_msg_id VARCHAR(64) NULL, + error_code VARCHAR(64) NULL, + error_message VARCHAR(1000) NULL, + foreground_session VARCHAR(160) NULL, + foreground_version BIGINT NULL, + next_retry_time DATETIME(3) NOT NULL, + send_time DATETIME(3) NULL, + tenant_id BIGINT NOT NULL DEFAULT 0, + creator VARCHAR(64) NULL DEFAULT '', + create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updater VARCHAR(64) NULL DEFAULT '', + update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted BIT(1) NOT NULL DEFAULT b'0', + PRIMARY KEY (id), + UNIQUE KEY uk_task_device (task_id, member_id, registration_id), + KEY idx_task_pending (task_id, status, next_retry_time), + KEY idx_batch (task_id, cid), + KEY idx_member_task (member_id, task_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='App推送设备记录'; + +-- 动态菜单按组件路径识别,可重复执行。优先复用现有 App 管理目录。 +SET @app_parent = (SELECT id FROM system_menu + WHERE deleted = b'0' AND type = 1 AND (path IN ('app','/app') OR name IN ('App管理','APP管理','应用管理')) + ORDER BY id LIMIT 1); +INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component, + component_name, status, visible, keep_alive, always_show, creator, updater) +SELECT 'App管理', '', 1, 80, 0, '/app', 'ep:cellphone', '', '', 0, b'1', b'1', b'1', '1', '1' +WHERE @app_parent IS NULL; +SET @app_parent = COALESCE(@app_parent, LAST_INSERT_ID()); +SET @push_menu = (SELECT id FROM system_menu + WHERE deleted = b'0' AND component = 'app/push/index' ORDER BY id LIMIT 1); +INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component, + component_name, status, visible, keep_alive, always_show, creator, updater) +SELECT '推送管理', 'member:push:query', 2, 20, @app_parent, 'push', 'ep:bell', + 'app/push/index', 'AppPush', 0, b'1', b'1', b'1', '1', '1' +WHERE @push_menu IS NULL; +SET @push_menu = COALESCE(@push_menu, LAST_INSERT_ID()); +UPDATE system_menu SET permission = 'member:push:query', component_name = 'AppPush' +WHERE id = @push_menu; +INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component, + component_name, status, visible, keep_alive, always_show, creator, updater) +SELECT '手动推送', 'member:push:send', 3, 1, @push_menu, '', '', '', '', 0, b'1', b'1', b'1', '1', '1' +WHERE NOT EXISTS (SELECT 1 FROM system_menu WHERE deleted = b'0' AND permission = 'member:push:send'); + +-- 运行配置 +-- API 和 IM 配置相同的 IM_PUSH_CALLBACK_SECRET。 +-- IM_PUSH_CALLBACK_URL 指向业务 API 的 /admin-api/third-callback/im-private-message。 +-- JPUSH_APP_KEY、JPUSH_MASTER_SECRET、JPUSH_APNS_PRODUCTION 使用聊天应用的极光配置。 diff --git a/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java b/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java index c41c6e0..8d78bfd 100644 --- a/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java +++ b/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java @@ -1,5 +1,7 @@ package cn.iocoder.yudao.module.infra.api.file; +import java.io.File; + /** * 文件 API 接口 * @@ -38,4 +40,10 @@ public interface FileApi { */ String createFile(String name, String path, byte[] content); + /** 保存已校验的大文件,调用方负责临时文件的生命周期,存储实现使用文件流。 */ + String createFile(String name, String path, File file, String contentType); + + /** 仅清理版本包上传失败产生的专用路径对象,由可信后台服务调用。 */ + void deleteVersionPackageFile(String url); + } diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java index 05fb946..1db0ef8 100644 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java @@ -5,6 +5,7 @@ import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import javax.annotation.Resource; +import java.io.File; /** * 文件 API 实现类 @@ -23,4 +24,16 @@ public class FileApiImpl implements FileApi { return fileService.createFile(name, path, content); } + /** 使用存储客户端的文件上传能力,避免将安装包读入 Java 堆。 */ + @Override + public String createFile(String name, String path, File file, String contentType) { + return fileService.createFile(name, path, file, contentType); + } + + /** 上传事务未能登记可信包时补偿清理文件,公共删除入口仍保护版本包。 */ + @Override + public void deleteVersionPackageFile(String url) { + fileService.deleteVersionPackageFile(url); + } + } diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java index 8b2ce4f..084fe60 100644 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java @@ -27,6 +27,7 @@ import javax.validation.Valid; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; import static cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils.writeAttachment; +import static cn.iocoder.yudao.module.infra.service.file.FileServiceImpl.validatePublicUploadPath; @Tag(name = "管理后台 - 文件存储") @RestController @@ -43,6 +44,7 @@ public class FileController { public CommonResult uploadFile(FileUploadReqVO uploadReqVO) throws Exception { MultipartFile file = uploadReqVO.getFile(); String path = uploadReqVO.getPath(); + validatePublicUploadPath(path); return success(fileService.createFile(file.getOriginalFilename(), path, IoUtil.readBytes(file.getInputStream()))); } @@ -82,7 +84,26 @@ public class FileController { // 解码,解决中文路径的问题 https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/807/ path = URLUtil.decode(path); - // 读取内容 + // 安装包单独流式输出,既有图片/视频响应继续复用原有 MIME 与附件行为。 + if (("/" + path.replace('\\', '/')).contains("/version-packages/")) { + if (!path.startsWith("version-packages/") || path.contains("..") || path.contains("\\")) { + response.setStatus(HttpStatus.NOT_FOUND.value()); + return; + } + FileDO metadata = fileService.getFileMetadata(configId, path); + if (metadata == null) { + response.setStatus(HttpStatus.NOT_FOUND.value()); + return; + } + response.setContentType("application/octet-stream"); + response.setContentLengthLong(metadata.getSize()); + response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + URLUtil.encode(path)); + if (!fileService.writeFileContent(configId, path, response.getOutputStream())) { + response.reset(); + response.setStatus(HttpStatus.NOT_FOUND.value()); + } + return; + } byte[] content = fileService.getFileContent(configId, path); if (content == null) { log.warn("[getFileContent][configId({}) path({}) 文件不存在]", configId, path); diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/FileUploadLimitConfiguration.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/FileUploadLimitConfiguration.java new file mode 100644 index 0000000..5b7bd3a --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/FileUploadLimitConfiguration.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.infra.framework.file.config; + +import cn.iocoder.yudao.framework.web.config.WebProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import javax.annotation.Resource; +import java.util.List; + +/** Servlet 支持大安装包后,继续为其余上传/导入入口保留原来的 16MB/32MB 限制。 */ +@Configuration +public class FileUploadLimitConfiguration implements WebMvcConfigurer { + /** 普通上传原有单文件上限,单位字节。 */ + private static final long REGULAR_FILE_LIMIT = 16L * 1024 * 1024; + /** 普通上传原有总文件上限,单位字节。 */ + private static final long REGULAR_TOTAL_LIMIT = 32L * 1024 * 1024; + /** 复用项目配置的后台 API 前缀。 */ + @Resource private WebProperties webProperties; + + /** multipart 解析直接落盘,业务读取文件前在拦截器内检查普通入口容量。 */ + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new org.springframework.web.servlet.HandlerInterceptor() { + /** 仅专用且带按钮权限的版本包路由可使用 Servlet 的大包上限。 */ + @Override + public boolean preHandle(javax.servlet.http.HttpServletRequest request, + javax.servlet.http.HttpServletResponse response, Object handler) { + String packageUpload = request.getContextPath() + webProperties.getAdminApi().getPrefix() + + "/member/app-version/upload-package"; + if (!(request instanceof MultipartHttpServletRequest) || + ("POST".equals(request.getMethod()) && packageUpload.equals(request.getRequestURI()))) { + return true; + } + long total = 0; + if (request.getContentLengthLong() > REGULAR_TOTAL_LIMIT) { + throw new MaxUploadSizeExceededException(REGULAR_TOTAL_LIMIT); + } + for (List files : ((MultipartHttpServletRequest) request).getMultiFileMap().values()) { + for (MultipartFile file : files) { + if (file.getSize() > REGULAR_FILE_LIMIT) { + throw new MaxUploadSizeExceededException(REGULAR_FILE_LIMIT); + } + total += file.getSize(); + } + } + if (total > REGULAR_TOTAL_LIMIT) { + throw new MaxUploadSizeExceededException(REGULAR_TOTAL_LIMIT); + } + return true; + } + }); + } +} diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java index 053b3c5..4769a72 100644 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java @@ -1,6 +1,8 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client; import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO; +import java.io.File; +import java.io.OutputStream; /** * 文件客户端 @@ -26,6 +28,12 @@ public interface FileClient { */ String upload(byte[] content, String path, String type) throws Exception; + /** 从磁盘文件上传,调用方保留文件所有权,供 APK 等大文件使用。 */ + String upload(File file, String path, String type) throws Exception; + + /** 将存储对象写到响应流,返回是否存在;实现不得关闭调用方输出流。 */ + boolean writeContent(String path, OutputStream output) throws Exception; + /** * 删除文件 * diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/db/DBFileClient.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/db/DBFileClient.java index 0a050d3..2533083 100644 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/db/DBFileClient.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/db/DBFileClient.java @@ -8,6 +8,15 @@ import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClie import java.util.Comparator; import java.util.List; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.sql.PreparedStatement; +import javax.sql.DataSource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.ResultSetExtractor; +import cn.hutool.core.io.IoUtil; /** * 基于 DB 存储的文件客户端的配置类 @@ -41,6 +50,46 @@ public class DBFileClient extends AbstractFileClient { fileContentMapper.deleteByConfigIdAndPath(getId(), path); } + /** BLOB 写入采用 JDBC 文件流,保留已有数据库存储配置。 */ + @Override + public String upload(File file, String path, String type) throws Exception { + // 现有表使用 MEDIUMBLOB,保持其容量;大 APK 应配置已有 OSS/S3 等文件存储。 + if (file.length() > 16_777_215L) { + throw new IllegalArgumentException("当前数据库文件存储最大支持 16MB,请先将默认存储切换为 OSS/S3、本地、FTP 或 SFTP 后上传大包"); + } + JdbcTemplate jdbc = new JdbcTemplate(SpringUtil.getBean(DataSource.class)); + try (FileInputStream input = new FileInputStream(file)) { + jdbc.update(connection -> { + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO infra_file_content (config_id, path, content) VALUES (?, ?, ?)"); + statement.setLong(1, getId()); + statement.setString(2, path); + statement.setBinaryStream(3, input, file.length()); + return statement; + }); + } + return super.formatFileUrl(config.getDomain(), path); + } + + /** 在 ResultSet 生命周期内复制 BLOB 流,避免映射为 byte[]。 */ + @Override + public boolean writeContent(String path, OutputStream output) { + JdbcTemplate jdbc = new JdbcTemplate(SpringUtil.getBean(DataSource.class)); + return Boolean.TRUE.equals(jdbc.query( + "SELECT content FROM infra_file_content WHERE config_id = ? AND path = ? AND deleted = 0 ORDER BY id DESC LIMIT 1", + (ResultSetExtractor) result -> { + if (!result.next()) { + return false; + } + try (InputStream input = result.getBinaryStream(1)) { + IoUtil.copy(input, output); + } catch (java.io.IOException ex) { + throw new java.sql.SQLException("文件流读取失败", ex); + } + return true; + }, getId(), path)); + } + @Override public byte[] getContent(String path) { List list = fileContentMapper.selectListByConfigIdAndPath(getId(), path); diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/ftp/FtpFileClient.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/ftp/FtpFileClient.java index 062d838..5efb70e 100644 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/ftp/FtpFileClient.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/ftp/FtpFileClient.java @@ -10,6 +10,9 @@ import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClie import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.OutputStream; /** * Ftp 文件客户端 @@ -38,7 +41,7 @@ public class FtpFileClient extends AbstractFileClient { } @Override - public String upload(byte[] content, String path, String type) { + public synchronized String upload(byte[] content, String path, String type) { // 执行写入 String filePath = getFilePath(path); String fileName = FileUtil.getName(filePath); @@ -53,14 +56,41 @@ public class FtpFileClient extends AbstractFileClient { } @Override - public void delete(String path) { + public synchronized void delete(String path) { String filePath = getFilePath(path); ftp.reconnectIfTimeout(); ftp.delFile(filePath); } + /** FTP 客户端共享连接,加锁覆盖整个传输过程以避免并发串流。 */ @Override - public byte[] getContent(String path) { + public synchronized String upload(File file, String path, String type) throws Exception { + String filePath = getFilePath(path); + String fileName = FileUtil.getName(filePath); + ftp.reconnectIfTimeout(); + try (FileInputStream input = new FileInputStream(file)) { + if (!ftp.upload(StrUtil.removeSuffix(filePath, fileName), fileName, input)) { + throw new FtpException("安装包上传失败"); + } + } + return super.formatFileUrl(config.getDomain(), path); + } + + /** FTP 下载直接写入输出流。 */ + @Override + public synchronized boolean writeContent(String path, OutputStream output) { + String filePath = getFilePath(path); + String fileName = FileUtil.getName(filePath); + ftp.reconnectIfTimeout(); + if (!ftp.exist(filePath)) { + return false; + } + ftp.download(StrUtil.removeSuffix(filePath, fileName), fileName, output); + return true; + } + + @Override + public synchronized byte[] getContent(String path) { String filePath = getFilePath(path); String fileName = FileUtil.getName(filePath); String dir = StrUtil.removeSuffix(filePath, fileName); @@ -74,4 +104,4 @@ public class FtpFileClient extends AbstractFileClient { return config.getBasePath() + path; } -} \ No newline at end of file +} diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java index a919690..5966b4c 100644 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java @@ -4,6 +4,10 @@ import cn.hutool.core.io.FileUtil; import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; import java.io.File; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; /** * 本地文件客户端 @@ -39,6 +43,26 @@ public class LocalFileClient extends AbstractFileClient { FileUtil.del(filePath); } + /** 大文件直接在磁盘间复制,避免堆内存随安装包大小增长。 */ + @Override + public String upload(File file, String path, String type) throws Exception { + Path target = new File(getFilePath(path)).toPath(); + Files.createDirectories(target.getParent()); + Files.copy(file.toPath(), target, StandardCopyOption.REPLACE_EXISTING); + return super.formatFileUrl(config.getDomain(), path); + } + + /** 流式输出本地文件,调用方输出流保持打开。 */ + @Override + public boolean writeContent(String path, OutputStream output) throws Exception { + Path source = new File(getFilePath(path)).toPath(); + if (!Files.isRegularFile(source)) { + return false; + } + Files.copy(source, output); + return true; + } + @Override public byte[] getContent(String path) { String filePath = getFilePath(path); diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java index 5c76e1a..a526cca 100644 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java @@ -12,8 +12,12 @@ import com.amazonaws.services.s3.AmazonS3Client; import com.amazonaws.services.s3.AmazonS3ClientBuilder; import com.amazonaws.services.s3.model.ObjectMetadata; import com.amazonaws.services.s3.model.S3Object; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.amazonaws.services.s3.model.AmazonS3Exception; import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.OutputStream; import java.util.Date; import java.util.concurrent.TimeUnit; @@ -100,6 +104,30 @@ public class S3FileClient extends AbstractFileClient { client.deleteObject(config.getBucket(), path); } + /** SDK 直接读取磁盘文件,网络重试时能够重新打开文件。 */ + @Override + public String upload(File file, String path, String type) { + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentType(type); + metadata.setContentLength(file.length()); + client.putObject(new PutObjectRequest(config.getBucket(), path, file).withMetadata(metadata)); + return config.getDomain() + "/" + path; + } + + /** 使用存储 SDK 读取对象,访问地址始终来自已有存储配置。 */ + @Override + public boolean writeContent(String path, OutputStream output) throws Exception { + try (S3Object object = client.getObject(config.getBucket(), path)) { + IoUtil.copy(object.getObjectContent(), output); + return true; + } catch (AmazonS3Exception ex) { + if (ex.getStatusCode() == 404) { + return false; + } + throw ex; + } + } + @Override public byte[] getContent(String path) throws Exception { S3Object tempS3Object = client.getObject(config.getBucket(), path); diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/sftp/SftpFileClient.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/sftp/SftpFileClient.java index 3ebe782..3be87e5 100644 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/sftp/SftpFileClient.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/sftp/SftpFileClient.java @@ -6,6 +6,8 @@ import cn.iocoder.yudao.framework.common.util.io.FileUtils; import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; import java.io.File; +import java.io.OutputStream; +import java.nio.file.Files; /** * Sftp 文件客户端 @@ -31,7 +33,7 @@ public class SftpFileClient extends AbstractFileClient { } @Override - public String upload(byte[] content, String path, String type) { + public synchronized String upload(byte[] content, String path, String type) { // 执行写入 String filePath = getFilePath(path); File file = FileUtils.createTempFile(content); @@ -41,13 +43,39 @@ public class SftpFileClient extends AbstractFileClient { } @Override - public void delete(String path) { + public synchronized void delete(String path) { String filePath = getFilePath(path); sftp.delFile(filePath); } + /** 共享 SFTP 连接的安装包传输串行执行,直接读取调用方磁盘文件。 */ @Override - public byte[] getContent(String path) { + public synchronized String upload(File file, String path, String type) { + String filePath = getFilePath(path); + sftp.mkDirs(FileUtil.getParent(filePath, 1)); + sftp.upload(filePath, file); + return super.formatFileUrl(config.getDomain(), path); + } + + /** 先下载至临时磁盘再写出,兼容当前 Hutool SFTP 的文件下载接口。 */ + @Override + public synchronized boolean writeContent(String path, OutputStream output) throws Exception { + String filePath = getFilePath(path); + if (!sftp.exist(filePath)) { + return false; + } + File temporary = File.createTempFile("stored-package-", ".tmp"); + try { + sftp.download(filePath, temporary); + Files.copy(temporary.toPath(), output); + return true; + } finally { + Files.deleteIfExists(temporary.toPath()); + } + } + + @Override + public synchronized byte[] getContent(String path) { String filePath = getFilePath(path); File destFile = FileUtils.createTempFile(); sftp.download(filePath, destFile); diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java index 3ca9a24..03cb8f2 100644 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java @@ -5,6 +5,8 @@ import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReq import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO; +import java.io.File; +import java.io.OutputStream; /** * 文件 Service 接口 @@ -31,6 +33,18 @@ public interface FileService { */ String createFile(String name, String path, byte[] content); + /** 保存磁盘文件并登记真实文件长度,供可信版本包上传使用。 */ + String createFile(String name, String path, File file, String contentType); + + /** 流式下载到调用方输出流,返回对象是否存在。 */ + boolean writeFileContent(Long configId, String path, OutputStream output) throws Exception; + + /** 读取已登记的真实长度,供大包下载响应展示进度。 */ + FileDO getFileMetadata(Long configId, String path); + + /** 根据已登记 URL 清理上传失败的专用版本包对象和文件记录。 */ + void deleteVersionPackageFile(String url); + /** * 创建文件 * diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java index 8efa5fe..87c868d 100644 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java @@ -17,6 +17,8 @@ import lombok.SneakyThrows; import org.springframework.stereotype.Service; import javax.annotation.Resource; +import java.io.File; +import java.io.OutputStream; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_NOT_EXISTS; @@ -29,6 +31,9 @@ import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_NOT_EX @Service public class FileServiceImpl implements FileService { + /** 版本包专用命名空间,通用客户端上传接口不能覆盖已校验包。 */ + public static final String VERSION_PACKAGE_PREFIX = "version-packages/"; + @Resource private FileConfigService fileConfigService; @@ -43,6 +48,7 @@ public class FileServiceImpl implements FileService { @Override @SneakyThrows public String createFile(String name, String path, byte[] content) { + validatePublicUploadPath(path); // 计算默认的 path 名 String type = FileTypeUtils.getMineType(content, name); if (StrUtil.isEmpty(path)) { @@ -72,6 +78,7 @@ public class FileServiceImpl implements FileService { @Override public Long createFile(FileCreateReqVO createReqVO) { + validatePublicUploadPath(createReqVO.getPath()); FileDO file = BeanUtils.toBean(createReqVO, FileDO.class); fileMapper.insert(file); return file.getId(); @@ -81,6 +88,7 @@ public class FileServiceImpl implements FileService { public void deleteFile(Long id) throws Exception { // 校验存在 FileDO file = validateFileExists(id); + validatePublicUploadPath(file.getPath()); // 从文件存储器中删除 FileClient client = fileConfigService.getFileClient(file.getConfigId()); @@ -108,10 +116,66 @@ public class FileServiceImpl implements FileService { @Override public FilePresignedUrlRespVO getFilePresignedUrl(String path) throws Exception { + validatePublicUploadPath(path); FileClient fileClient = fileConfigService.getMasterFileClient(); FilePresignedUrlRespDTO presignedObjectUrl = fileClient.getPresignedObjectUrl(path); return BeanUtils.toBean(presignedObjectUrl, FilePresignedUrlRespVO.class, object -> object.setConfigId(fileClient.getId())); } + /** 版本包必须通过专用可信上传入口创建,普通上传不能写入该路径。 */ + public static void validatePublicUploadPath(String path) { + if (path != null && ("/" + path.replace('\\', '/')).contains("/" + VERSION_PACKAGE_PREFIX)) { + throw new IllegalArgumentException("版本包请通过版本管理上传和维护"); + } + } + + /** 磁盘文件上传成功后登记真实长度,登记失败时清理远端孤立对象。 */ + @Override + @SneakyThrows + public String createFile(String name, String path, File source, String contentType) { + Assert.isTrue(source.isFile() && source.length() <= Integer.MAX_VALUE, "文件不存在或超过存储上限"); + FileClient client = fileConfigService.getMasterFileClient(); + Assert.notNull(client, "客户端(master) 不能为空"); + try { + String url = client.upload(source, path, contentType); + fileMapper.insert(new FileDO().setConfigId(client.getId()).setName(name).setPath(path) + .setUrl(url).setType(contentType).setSize((int) source.length())); + return url; + } catch (Exception ex) { + try { + client.delete(path); + } catch (Exception cleanupException) { + ex.addSuppressed(cleanupException); + } + throw ex; + } + } + + /** 由已有存储客户端定位对象,避免从外部 URL 拉取文件。 */ + @Override + public boolean writeFileContent(Long configId, String path, OutputStream output) throws Exception { + FileClient client = fileConfigService.getFileClient(configId); + Assert.notNull(client, "文件存储配置不存在"); + return client.writeContent(path, output); + } + + /** 通过存储配置与对象路径匹配真实文件登记信息。 */ + @Override + public FileDO getFileMetadata(Long configId, String path) { + return fileMapper.selectOne(FileDO::getConfigId, configId, FileDO::getPath, path); + } + + /** 仅允许内部服务补偿清理已登记的专用包,地址不用于发起 HTTP 请求。 */ + @Override + @SneakyThrows + public void deleteVersionPackageFile(String url) { + FileDO file = fileMapper.selectOne(FileDO::getUrl, url); + if (file == null || !file.getPath().startsWith(VERSION_PACKAGE_PREFIX)) { + return; + } + fileConfigService.getFileClient(file.getConfigId()).delete(file.getPath()); + fileMapper.deleteById(file.getId()); + } + } diff --git a/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileControllerStreamingTest.java b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileControllerStreamingTest.java new file mode 100644 index 0000000..00a087b --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileControllerStreamingTest.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.infra.controller.admin.file; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO; +import cn.iocoder.yudao.module.infra.service.file.FileService; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import org.springframework.mock.web.*; +import java.io.OutputStream; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class FileControllerStreamingTest extends BaseMockitoUnitTest { + @InjectMocks private FileController controller; + @Mock private FileService service; + + @Test + void packageDownloadStreamsWithRealContentLength() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/infra/file/1/get/version-packages/test.apk"); + MockHttpServletResponse response = new MockHttpServletResponse(); + when(service.getFileMetadata(1L, "version-packages/test.apk")).thenReturn(new FileDO().setSize(3)); + when(service.writeFileContent(eq(1L), eq("version-packages/test.apk"), any())).thenAnswer(invocation -> { + ((OutputStream) invocation.getArgument(2)).write(new byte[]{1, 2, 3}); + return true; + }); + controller.getFileContent(request, response, 1L); + assertEquals(3, response.getContentLengthLong()); + assertArrayEquals(new byte[]{1, 2, 3}, response.getContentAsByteArray()); + verify(service, never()).getFileContent(anyLong(), anyString()); + } + + @Test + void packagePathAliasesAreRejectedBeforeAnyStorageRead() throws Exception { + for (String path : new String[]{"other/../version-packages/test.apk", "version-packages/../test.apk", "other%5C..%5Cversion-packages%5Ctest.apk"}) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/infra/file/1/get/" + path); + MockHttpServletResponse response = new MockHttpServletResponse(); + controller.getFileContent(request, response, 1L); + assertEquals(404, response.getStatus()); + } + verifyNoInteractions(service); + } + + @Test + void missingPackageClearsContentLengthBeforeReturningNotFound() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/infra/file/1/get/version-packages/test.apk"); + MockHttpServletResponse response = new MockHttpServletResponse(); + when(service.getFileMetadata(1L, "version-packages/test.apk")).thenReturn(new FileDO().setSize(300)); + when(service.writeFileContent(eq(1L), anyString(), any())).thenReturn(false); + controller.getFileContent(request, response, 1L); + assertEquals(404, response.getStatus()); + assertFalse(response.containsHeader("Content-Length")); + } +} diff --git a/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/FileStreamingTest.java b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/FileStreamingTest.java new file mode 100644 index 0000000..c289a1b --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/FileStreamingTest.java @@ -0,0 +1,118 @@ +package cn.iocoder.yudao.module.infra.framework.file; + +import cn.iocoder.yudao.module.infra.framework.file.core.client.local.*; +import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.*; +import cn.iocoder.yudao.module.infra.framework.file.core.client.db.*; +import cn.iocoder.yudao.module.infra.service.file.FileServiceImpl; +import cn.iocoder.yudao.module.infra.service.file.FileConfigService; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient; +import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper; +import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; +import java.io.*; +import java.nio.file.*; +import java.nio.charset.StandardCharsets; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FileStreamingTest { + @TempDir Path directory; + + @Test + void localFileUploadAndDownloadKeepFileContents() throws Exception { + LocalFileClient client = new LocalFileClient(1L, new LocalFileClientConfig() + .setBasePath(directory.resolve("storage").toString()).setDomain("https://files.test")); + client.init(); + Path source = directory.resolve("source.apk"); + byte[] data = "version package bytes".getBytes(StandardCharsets.UTF_8); + Files.write(source, data); + assertTrue(client.upload(source.toFile(), "version-packages/test.apk", "application/octet-stream").contains("version-packages/test.apk")); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + assertTrue(client.writeContent("version-packages/test.apk", output)); + assertArrayEquals(data, output.toByteArray()); + assertFalse(client.writeContent("version-packages/missing.apk", output)); + } + + @Test + void s3UsesSdkFileRequestAndClosesDownloadObject() throws Exception { + S3FileClient client = new S3FileClient(1L, new S3FileClientConfig().setBucket("test").setDomain("https://files.test")); + AmazonS3Client sdk = mock(AmazonS3Client.class); + ReflectionTestUtils.setField(client, "client", sdk); + File source = directory.resolve("source.apk").toFile(); + Files.write(source.toPath(), new byte[]{1, 2, 3}); + client.upload(source, "version-packages/test.apk", "application/octet-stream"); + ArgumentCaptor request = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(sdk).putObject(request.capture()); + assertEquals(source, request.getValue().getFile()); + assertEquals(3, request.getValue().getMetadata().getContentLength()); + S3Object object = new S3Object(); + object.setObjectContent(new ByteArrayInputStream(new byte[]{1, 2, 3})); + when(sdk.getObject("test", "version-packages/test.apk")).thenReturn(object); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + assertTrue(client.writeContent("version-packages/test.apk", output)); + assertArrayEquals(new byte[]{1, 2, 3}, output.toByteArray()); + } + + @Test + void databaseCapacityIsCheckedBeforeOpeningConnections() throws Exception { + File source = directory.resolve("large.apk").toFile(); + try (RandomAccessFile file = new RandomAccessFile(source, "rw")) { + file.setLength(16_777_216L); + } + DBFileClient client = new DBFileClient(1L, new DBFileClientConfig().setDomain("https://files.test")); + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> client.upload(source, "version-packages/test.apk", "application/octet-stream")); + assertTrue(error.getMessage().contains("OSS/S3")); + } + + @Test + void interruptedPackageUploadRemovesPartialRemoteObject() throws Exception { + FileServiceImpl service = new FileServiceImpl(); + FileConfigService config = mock(FileConfigService.class); + FileMapper mapper = mock(FileMapper.class); + FileClient client = mock(FileClient.class); + ReflectionTestUtils.setField(service, "fileConfigService", config); + ReflectionTestUtils.setField(service, "fileMapper", mapper); + when(config.getMasterFileClient()).thenReturn(client); + File source = directory.resolve("partial.apk").toFile(); + Files.write(source.toPath(), new byte[]{1, 2, 3}); + when(client.upload(source, "version-packages/partial.apk", "application/octet-stream")) + .thenThrow(new IOException("连接中断")); + assertThrows(IOException.class, () -> service.createFile("partial.apk", "version-packages/partial.apk", + source, "application/octet-stream")); + verify(client).delete("version-packages/partial.apk"); + verifyNoInteractions(mapper); + } + + @Test + void metadataFailureRemovesUploadedPackageObject() throws Exception { + FileServiceImpl service = new FileServiceImpl(); + FileConfigService config = mock(FileConfigService.class); + FileMapper mapper = mock(FileMapper.class); + FileClient client = mock(FileClient.class); + ReflectionTestUtils.setField(service, "fileConfigService", config); + ReflectionTestUtils.setField(service, "fileMapper", mapper); + when(config.getMasterFileClient()).thenReturn(client); + File source = directory.resolve("complete.apk").toFile(); + Files.write(source.toPath(), new byte[]{1, 2, 3}); + when(client.upload(source, "version-packages/complete.apk", "application/octet-stream")) + .thenReturn("https://files.test/version-packages/complete.apk"); + when(mapper.insert(any(FileDO.class))).thenThrow(new IllegalStateException("数据库写入失败")); + assertThrows(IllegalStateException.class, () -> service.createFile("complete.apk", "version-packages/complete.apk", + source, "application/octet-stream")); + verify(client).delete("version-packages/complete.apk"); + } + + @Test + void generalUploadCannotOverwriteOrDeleteReservedPackagePaths() { + assertThrows(IllegalArgumentException.class, () -> FileServiceImpl.validatePublicUploadPath("version-packages/test.apk")); + assertThrows(IllegalArgumentException.class, () -> FileServiceImpl.validatePublicUploadPath("folder/../version-packages/test.apk")); + assertThrows(IllegalArgumentException.class, () -> FileServiceImpl.validatePublicUploadPath("version-packages\\test.apk")); + assertDoesNotThrow(() -> FileServiceImpl.validatePublicUploadPath("ordinary/test.png")); + } +} diff --git a/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/FileUploadLimitConfigurationTest.java b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/FileUploadLimitConfigurationTest.java new file mode 100644 index 0000000..f414ae8 --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/FileUploadLimitConfigurationTest.java @@ -0,0 +1,59 @@ +package cn.iocoder.yudao.module.infra.framework.file; + +import cn.iocoder.yudao.framework.web.config.WebProperties; +import cn.iocoder.yudao.module.infra.framework.file.config.FileUploadLimitConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartHttpServletRequest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FileUploadLimitConfigurationTest { + @Test + void onlyDedicatedPackageEndpointAllowsLargeFiles() throws Exception { + FileUploadLimitConfiguration config = new FileUploadLimitConfiguration(); + ReflectionTestUtils.setField(config, "webProperties", new WebProperties()); + InterceptorRegistry registry = new InterceptorRegistry(); + config.addInterceptors(registry); + List interceptors = ReflectionTestUtils.invokeMethod(registry, "getInterceptors"); + HandlerInterceptor interceptor = (HandlerInterceptor) interceptors.get(0); + MockMultipartHttpServletRequest ordinary = request("/admin-api/infra/file/upload", 20L * 1024 * 1024); + assertThrows(MaxUploadSizeExceededException.class, () -> interceptor.preHandle(ordinary, null, null)); + assertTrue(interceptor.preHandle(request("/admin-api/member/app-version/upload-package", 200L * 1024 * 1024), null, null)); + assertThrows(MaxUploadSizeExceededException.class, + () -> interceptor.preHandle(request("/app-api/infra/file/upload", 20L * 1024 * 1024), null, null)); + } + + @Test + void ordinaryMultipleFilesRetainOriginalTotalLimit() throws Exception { + FileUploadLimitConfiguration config = new FileUploadLimitConfiguration(); + ReflectionTestUtils.setField(config, "webProperties", new WebProperties()); + InterceptorRegistry registry = new InterceptorRegistry(); + config.addInterceptors(registry); + List interceptors = ReflectionTestUtils.invokeMethod(registry, "getInterceptors"); + MockMultipartHttpServletRequest request = request("/admin-api/import", 12L * 1024 * 1024); + request.addFile(file("second", 12L * 1024 * 1024)); + request.addFile(file("third", 12L * 1024 * 1024)); + HandlerInterceptor interceptor = (HandlerInterceptor) interceptors.get(0); + assertThrows(MaxUploadSizeExceededException.class, () -> interceptor.preHandle(request, null, null)); + } + + private MockMultipartHttpServletRequest request(String path, long size) { + MockMultipartHttpServletRequest request = new MockMultipartHttpServletRequest(); + request.setRequestURI(path); + request.setMethod("POST"); + request.addFile(file("file", size)); + return request; + } + + private MockMultipartFile file(String name, long size) { + MockMultipartFile file = spy(new MockMultipartFile(name, new byte[]{1})); + when(file.getSize()).thenReturn(size); + return file; + } +} diff --git a/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImplTest.java b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImplTest.java index ba0c8b1..86bb3a9 100644 --- a/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImplTest.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImplTest.java @@ -214,7 +214,7 @@ public class FileConfigServiceImplTest extends BaseDbUnitTest { // mock 获得 Client FileClient fileClient = mock(FileClient.class); when(fileClientFactory.getFileClient(eq(id))).thenReturn(fileClient); - when(fileClient.upload(any(), any(), any())).thenReturn("https://www.iocoder.cn"); + when(fileClient.upload(any(byte[].class), any(), any())).thenReturn("https://www.iocoder.cn"); // 调用,并断言 assertEquals("https://www.iocoder.cn", fileConfigService.testFileConfig(id)); diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/record/BrokerageRecordPageReqVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/record/BrokerageRecordPageReqVO.java index 58c30a4..72a3c1b 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/record/BrokerageRecordPageReqVO.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/record/BrokerageRecordPageReqVO.java @@ -20,6 +20,9 @@ public class BrokerageRecordPageReqVO extends PageParam { @Schema(description = "用户编号", example = "25973") private Long userId; + @Schema(description = "用户名,模糊匹配会员昵称", example = "张三") + private String userNickname; + @Schema(description = "来源会员编号", example = "1125") private Long sourceUserId; diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/withdraw/BrokerageWithdrawPageReqVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/withdraw/BrokerageWithdrawPageReqVO.java index b18ff74..a802eaa 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/withdraw/BrokerageWithdrawPageReqVO.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/withdraw/BrokerageWithdrawPageReqVO.java @@ -23,6 +23,9 @@ public class BrokerageWithdrawPageReqVO extends PageParam { @Schema(description = "用户编号", example = "11436") private Long userId; + @Schema(description = "用户名,模糊匹配会员昵称", example = "张三") + private String userNickname; + @Schema(description = "提现类型", example = "1") @InEnum(value = BrokerageWithdrawTypeEnum.class, message = "提现类型必须是 {value}") private Integer type; diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/AppBrokerageUserController.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/AppBrokerageUserController.java index 8aa8134..94c0e60 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/AppBrokerageUserController.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/AppBrokerageUserController.java @@ -1,10 +1,12 @@ package cn.iocoder.yudao.module.trade.controller.app.brokerage; +import cn.hutool.core.collection.CollUtil; import cn.hutool.core.date.LocalDateTimeUtil; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.module.member.api.user.MemberUserApi; import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; +import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO; import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.*; import cn.iocoder.yudao.module.trade.convert.brokerage.BrokerageRecordConvert; import cn.iocoder.yudao.module.trade.convert.brokerage.BrokerageUserConvert; @@ -30,6 +32,7 @@ import java.time.LocalDateTime; import java.util.Collections; import java.util.Map; import java.util.Optional; +import java.util.Set; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; @@ -93,7 +96,11 @@ public class AppBrokerageUserController { Long secondBrokerageUserCount = brokerageUserService.getBrokerageUserCountByBindUserId(userId, 2); // 拼接返回 - return success(BrokerageUserConvert.INSTANCE.convert(yesterdayPrice, withdrawPrice, firstBrokerageUserCount, secondBrokerageUserCount, brokerageUser)); + AppBrokerageUserMySummaryRespVO result = BrokerageUserConvert.INSTANCE.convert(yesterdayPrice, withdrawPrice, + firstBrokerageUserCount, secondBrokerageUserCount, brokerageUser); + Map summaries = brokerageRecordService.getIncomeSummaryMap(Collections.singleton(userId)); + result.setBrokerageSummary(summaries.getOrDefault(userId, new BrokerageIncomeSummaryRespDTO())); + return success(result); } @GetMapping("/rank-page-by-user-count") @@ -118,8 +125,19 @@ public class AppBrokerageUserController { @GetMapping("/child-summary-page") @Operation(summary = "获得下级分销统计分页") - public CommonResult> getBrokerageUserChildSummaryPage(AppBrokerageUserChildSummaryPageReqVO pageReqVO) { - PageResult pageResult = brokerageUserService.getBrokerageUserChildSummaryPage(pageReqVO, getLoginUserId()); + public CommonResult> getBrokerageUserChildSummaryPage(@Valid AppBrokerageUserChildSummaryPageReqVO pageReqVO) { + Long userId = getLoginUserId(); + PageResult pageResult = brokerageUserService.getBrokerageUserChildSummaryPage(pageReqVO, userId); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(pageResult); + } + Set pageUserIds = convertSet(pageResult.getList(), AppBrokerageUserChildSummaryRespVO::getId); + Map summaries = brokerageRecordService.getIncomeSummaryMap(pageUserIds); + Map contributions = brokerageRecordService.getContributionSummaryMap(userId, pageUserIds); + pageResult.getList().forEach(user -> { + user.setBrokerageSummary(summaries.getOrDefault(user.getId(), new BrokerageIncomeSummaryRespDTO())); + user.setContributionSummary(contributions.getOrDefault(user.getId(), new BrokerageIncomeSummaryRespDTO())); + }); return success(pageResult); } diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/record/AppBrokerageRecordPageReqVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/record/AppBrokerageRecordPageReqVO.java index 2100c23..65333e3 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/record/AppBrokerageRecordPageReqVO.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/record/AppBrokerageRecordPageReqVO.java @@ -28,4 +28,7 @@ public class AppBrokerageRecordPageReqVO extends PageParam { @InEnum(value = BrokerageRecordStatusEnum.class, message = "状态必须是 {value}") private Integer status; + @Schema(description = "仅查询有效分销收益,包含推广佣金和会员佣金") + private Boolean incomeOnly; + } diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/record/AppBrokerageRecordRespVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/record/AppBrokerageRecordRespVO.java index 993006e..50d39ba 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/record/AppBrokerageRecordRespVO.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/record/AppBrokerageRecordRespVO.java @@ -15,9 +15,18 @@ public class AppBrokerageRecordRespVO { @Schema(description = "业务编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") private String bizId; + @Schema(description = "业务类型,1 推广佣金,2 会员佣金,3 提现申请,4 提现驳回") + private Integer bizType; + @Schema(description = "分销标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "用户下单") private String title; + @Schema(description = "分销说明") + private String description; + + @Schema(description = "产生佣金的来源会员编号") + private Long sourceUserId; + @Schema(description = "分销金额,单位:分", requiredMode = Schema.RequiredMode.REQUIRED, example = "1000") private Integer price; @@ -30,4 +39,7 @@ public class AppBrokerageRecordRespVO { @Schema(description = "完成时间", requiredMode = Schema.RequiredMode.REQUIRED) private LocalDateTime finishTime; + @Schema(description = "解冻时间") + private LocalDateTime unfreezeTime; + } diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/user/AppBrokerageUserChildSummaryPageReqVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/user/AppBrokerageUserChildSummaryPageReqVO.java index 890500c..5729e42 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/user/AppBrokerageUserChildSummaryPageReqVO.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/user/AppBrokerageUserChildSummaryPageReqVO.java @@ -2,11 +2,14 @@ package cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user; import cn.iocoder.yudao.framework.common.pojo.PageParam; import cn.iocoder.yudao.framework.common.pojo.SortingField; +import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.hibernate.validator.constraints.Range; import javax.validation.constraints.NotNull; +import javax.validation.constraints.AssertTrue; +import java.util.Arrays; @Schema(description = "用户 App - 下级分销统计分页 Request VO") @Data @@ -15,13 +18,25 @@ public class AppBrokerageUserChildSummaryPageReqVO extends PageParam { public static final String SORT_FIELD_USER_COUNT = "userCount"; public static final String SORT_FIELD_ORDER_COUNT = "orderCount"; public static final String SORT_FIELD_PRICE = "price"; + public static final String SORT_FIELD_CONTRIBUTION_PRICE = "contributionPrice"; + public static final String SORT_FIELD_INCOME_PRICE = "incomePrice"; @Schema(description = "用户昵称", example = "李") // 模糊匹配 private String nickname; - @Schema(description = "排序字段", example = "userCount") + @Schema(description = "排序字段,支持 userCount、orderCount、price、contributionPrice、incomePrice,方向 asc 或 desc") private SortingField sortingField; + @JsonIgnore + @AssertTrue(message = "排序字段或排序方向不正确") + public boolean isSortingFieldValid() { + return sortingField == null || (Arrays.asList(SORT_FIELD_USER_COUNT, SORT_FIELD_ORDER_COUNT, + SORT_FIELD_PRICE, SORT_FIELD_CONTRIBUTION_PRICE, SORT_FIELD_INCOME_PRICE) + .contains(sortingField.getField()) + && (SortingField.ORDER_ASC.equalsIgnoreCase(sortingField.getOrder()) + || SortingField.ORDER_DESC.equalsIgnoreCase(sortingField.getOrder()))); + } + @Schema(description = "下级的级别", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") // 1 - 直接下级;2 - 间接下级 @NotNull(message = "下级的级别不能为空") @Range(min = 1, max = 2, message = "下级的级别只能是 {min} 或者 {max}") diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/user/AppBrokerageUserChildSummaryRespVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/user/AppBrokerageUserChildSummaryRespVO.java index 6bc4184..4380137 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/user/AppBrokerageUserChildSummaryRespVO.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/user/AppBrokerageUserChildSummaryRespVO.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user; +import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -19,7 +20,13 @@ public class AppBrokerageUserChildSummaryRespVO { private String avatar; @Schema(description = "佣金金额,单位:分", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") - private Integer brokeragePrice; + private Long brokeragePrice; + + @Schema(description = "该会员的分销收益汇总,金额单位为分") + private BrokerageIncomeSummaryRespDTO brokerageSummary = new BrokerageIncomeSummaryRespDTO(); + + @Schema(description = "该会员本人为当前登录会员贡献的收益汇总,金额单位为分") + private BrokerageIncomeSummaryRespDTO contributionSummary = new BrokerageIncomeSummaryRespDTO(); @Schema(description = "分销订单数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") private Integer brokerageOrderCount; diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/user/AppBrokerageUserMySummaryRespVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/user/AppBrokerageUserMySummaryRespVO.java index 8a0c387..76094c1 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/user/AppBrokerageUserMySummaryRespVO.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/user/AppBrokerageUserMySummaryRespVO.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user; +import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -7,6 +8,9 @@ import lombok.Data; @Data public class AppBrokerageUserMySummaryRespVO { + @Schema(description = "分销收益汇总,金额单位为分") + private BrokerageIncomeSummaryRespDTO brokerageSummary = new BrokerageIncomeSummaryRespDTO(); + @Schema(description = "昨天的佣金,单位:分", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") private Integer yesterdayPrice; diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageRecordMapper.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageRecordMapper.java index 872ed35..cd641d8 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageRecordMapper.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageRecordMapper.java @@ -36,8 +36,13 @@ import java.util.Map; public interface BrokerageRecordMapper extends BaseMapperX { default PageResult selectPage(BrokerageRecordPageReqVO reqVO) { + return selectPage(reqVO, (Collection) null); + } + + default PageResult selectPage(BrokerageRecordPageReqVO reqVO, Collection userIds) { return selectPage(reqVO, new LambdaQueryWrapperX() .eqIfPresent(BrokerageRecordDO::getUserId, reqVO.getUserId()) + .inIfPresent(BrokerageRecordDO::getUserId, userIds) .eqIfPresent(BrokerageRecordDO::getSourceUserId, reqVO.getSourceUserId()) .eqIfPresent(BrokerageRecordDO::getBizType, reqVO.getBizType()) .eqIfPresent(BrokerageRecordDO::getBizId, reqVO.getBizId()) diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageUserMapper.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageUserMapper.java index 6d6951f..d00dbeb 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageUserMapper.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageUserMapper.java @@ -166,13 +166,15 @@ public interface BrokerageUserMapper extends BaseMapperX { * @param status 状态 * @param ids 用户编号列表 * @param sortingField 排序字段 + * @param recipientUserId 贡献收益的收款会员编号 * @return 下级分销统计分页列表 */ IPage selectSummaryPageByUserId(Page page, @Param("bizType") Integer bizType, @Param("status") Integer status, @Param("ids") Collection ids, - @Param("sortingField") SortingField sortingField); + @Param("sortingField") SortingField sortingField, + @Param("recipientUserId") Long recipientUserId); /** * 获得被 bindUserIds 推广的用户编号数组 diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageWithdrawMapper.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageWithdrawMapper.java index 1942cc4..bd0ebf4 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageWithdrawMapper.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageWithdrawMapper.java @@ -24,8 +24,13 @@ import java.util.Map; public interface BrokerageWithdrawMapper extends BaseMapperX { default PageResult selectPage(BrokerageWithdrawPageReqVO reqVO) { + return selectPage(reqVO, (Collection) null); + } + + default PageResult selectPage(BrokerageWithdrawPageReqVO reqVO, Collection userIds) { return selectPage(reqVO, new LambdaQueryWrapperX() .eqIfPresent(BrokerageWithdrawDO::getUserId, reqVO.getUserId()) + .inIfPresent(BrokerageWithdrawDO::getUserId, userIds) .eqIfPresent(BrokerageWithdrawDO::getType, reqVO.getType()) .likeIfPresent(BrokerageWithdrawDO::getName, reqVO.getName()) .eqIfPresent(BrokerageWithdrawDO::getAccountNo, reqVO.getAccountNo()) diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java index efe8996..b292675 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java @@ -8,6 +8,8 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.number.MoneyUtils; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; +import cn.iocoder.yudao.module.member.api.user.MemberUserApi; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; import cn.iocoder.yudao.module.product.api.sku.ProductSkuApi; import cn.iocoder.yudao.module.product.api.sku.dto.ProductSkuRespDTO; import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi; @@ -39,6 +41,7 @@ import java.time.LocalDateTime; import java.util.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.getMaxValue; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.getMinValue; import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; @@ -66,6 +69,9 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService { @Resource private ProductSkuApi productSkuApi; + @Resource + private MemberUserApi memberUserApi; + @Override public Map getIncomeSummaryMap(Collection userIds) { if (CollUtil.isEmpty(userIds)) { @@ -101,7 +107,15 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService { @Override public PageResult getBrokerageRecordPage(BrokerageRecordPageReqVO pageReqVO) { - return brokerageRecordMapper.selectPage(pageReqVO); + Set userIds = null; + if (StrUtil.isNotBlank(pageReqVO.getUserNickname())) { + userIds = convertSet(memberUserApi.getUserListByNickname(pageReqVO.getUserNickname().trim()), + MemberUserRespDTO::getId); + if (CollUtil.isEmpty(userIds)) { + return PageResult.empty(); + } + } + return brokerageRecordMapper.selectPage(pageReqVO, userIds); } @Override diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java index d494061..f743e34 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java @@ -283,7 +283,8 @@ public class BrokerageUserServiceImpl implements BrokerageUserService { // 1.2 根据昵称过滤下级用户 List users = memberUserApi.getUserList(childIds); Map userMap = convertMapByFilter(users, - user -> StrUtil.contains(user.getNickname(), pageReqVO.getNickname()), + user -> StrUtil.isBlank(pageReqVO.getNickname()) + || StrUtil.contains(user.getNickname(), pageReqVO.getNickname()), MemberUserRespDTO::getId); if (CollUtil.isEmpty(userMap)) { return PageResult.empty(); @@ -292,7 +293,7 @@ public class BrokerageUserServiceImpl implements BrokerageUserService { // 2. 分页查询 IPage pageResult = brokerageUserMapper.selectSummaryPageByUserId( MyBatisUtils.buildPage(pageReqVO), BrokerageRecordBizTypeEnum.ORDER.getType(), - BrokerageRecordStatusEnum.SETTLEMENT.getStatus(), userMap.keySet(), pageReqVO.getSortingField() + BrokerageRecordStatusEnum.SETTLEMENT.getStatus(), userMap.keySet(), pageReqVO.getSortingField(), userId ); // 3. 拼接数据并返回 diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageWithdrawServiceImpl.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageWithdrawServiceImpl.java index c0589a4..0f62e9d 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageWithdrawServiceImpl.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageWithdrawServiceImpl.java @@ -2,10 +2,13 @@ package cn.iocoder.yudao.module.trade.service.brokerage; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.number.MoneyUtils; +import cn.iocoder.yudao.module.member.api.user.MemberUserApi; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; import cn.iocoder.yudao.module.pay.api.refund.dto.PayTransferCreateRespDTO; import cn.iocoder.yudao.module.pay.api.transfer.PayTransferApi; import cn.iocoder.yudao.module.pay.api.transfer.dto.PayTransferCreateReqDTO; @@ -73,6 +76,9 @@ public class BrokerageWithdrawServiceImpl implements BrokerageWithdrawService { @Resource private PayWalletApi payWalletApi; + @Resource + private MemberUserApi memberUserApi; + @Resource private Validator validator; @@ -190,7 +196,15 @@ public class BrokerageWithdrawServiceImpl implements BrokerageWithdrawService { @Override public PageResult getBrokerageWithdrawPage(BrokerageWithdrawPageReqVO pageReqVO) { - return brokerageWithdrawMapper.selectPage(pageReqVO); + Set userIds = null; + if (StrUtil.isNotBlank(pageReqVO.getUserNickname())) { + userIds = convertSet(memberUserApi.getUserListByNickname(pageReqVO.getUserNickname().trim()), + MemberUserRespDTO::getId); + if (CollUtil.isEmpty(userIds)) { + return PageResult.empty(); + } + } + return brokerageWithdrawMapper.selectPage(pageReqVO, userIds); } @Override diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/resources/mapper/brokerage/BrokerageUserMapper.xml b/yudao-module-mall/yudao-module-trade-biz/src/main/resources/mapper/brokerage/BrokerageUserMapper.xml index 066f75d..db0b831 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/resources/mapper/brokerage/BrokerageUserMapper.xml +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/resources/mapper/brokerage/BrokerageUserMapper.xml @@ -5,6 +5,10 @@ diff --git a/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/AppBrokerageIncomeControllerTest.java b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/AppBrokerageIncomeControllerTest.java new file mode 100644 index 0000000..f78eec0 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/AppBrokerageIncomeControllerTest.java @@ -0,0 +1,227 @@ +package cn.iocoder.yudao.module.trade.controller.app.brokerage; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.member.api.user.MemberUserApi; +import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO; +import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.record.BrokerageRecordPageReqVO; +import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.record.AppBrokerageRecordPageReqVO; +import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.record.AppBrokerageRecordRespVO; +import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserChildSummaryPageReqVO; +import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserChildSummaryRespVO; +import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserMySummaryRespVO; +import cn.iocoder.yudao.module.trade.convert.brokerage.BrokerageRecordConvert; +import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageRecordDO; +import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageUserDO; +import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum; +import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordStatusEnum; +import cn.iocoder.yudao.module.trade.service.brokerage.BrokerageRecordService; +import cn.iocoder.yudao.module.trade.service.brokerage.BrokerageUserService; +import cn.iocoder.yudao.module.trade.service.brokerage.BrokerageWithdrawService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AppBrokerageIncomeControllerTest extends BaseMockitoUnitTest { + + @InjectMocks private AppBrokerageUserController userController; + @InjectMocks private AppBrokerageRecordController recordController; + @Mock private BrokerageUserService brokerageUserService; + @Mock private BrokerageRecordService brokerageRecordService; + @Mock private BrokerageWithdrawService brokerageWithdrawService; + @Mock private MemberUserApi memberUserApi; + + @BeforeEach + void login() { + MockHttpServletRequest request = new MockHttpServletRequest(); + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(300L).setUserType(1), request); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @AfterEach + void clearLogin() { + SecurityContextHolder.clearContext(); + RequestContextHolder.resetRequestAttributes(); + } + + @Test + void summaryUsesCurrentMemberIncomeAndKeepsBalanceFields() { + when(brokerageUserService.getBrokerageUser(300L)).thenReturn(new BrokerageUserDO() + .setId(300L).setBrokeragePrice(20841).setFrozenPrice(27)); + when(brokerageRecordService.getIncomeSummaryMap(Collections.singleton(300L))).thenReturn( + Collections.singletonMap(300L, summary(3000000000L, 150L))); + when(brokerageUserService.getBrokerageUserCountByBindUserId(300L, 1)).thenReturn(43L); + when(brokerageUserService.getBrokerageUserCountByBindUserId(300L, 2)).thenReturn(13L); + + AppBrokerageUserMySummaryRespVO result = userController.getBrokerageUserSummary().getData(); + + assertThat(result.getBrokerageSummary().getSettledPrice()).isEqualTo(3000000000L); + assertThat(result.getBrokerageSummary().getPendingPrice()).isEqualTo(150L); + assertThat(result.getBrokeragePrice()).isEqualTo(20841); + assertThat(result.getFrozenPrice()).isEqualTo(27); + assertThat(result.getWithdrawPrice()).isZero(); + assertThat(result.getFirstBrokerageUserCount()).isEqualTo(43L); + assertThat(result.getSecondBrokerageUserCount()).isEqualTo(13L); + verify(brokerageRecordService).getIncomeSummaryMap(Collections.singleton(300L)); + verify(brokerageRecordService).getSummaryPriceByUserId(eq(300L), eq(BrokerageRecordBizTypeEnum.ORDER), + eq(BrokerageRecordStatusEnum.SETTLEMENT), any(), any()); + } + + @Test + void summaryWithoutIncomeReturnsZeroTotals() { + AppBrokerageUserMySummaryRespVO result = userController.getBrokerageUserSummary().getData(); + assertThat(result.getBrokerageSummary().getSettledPrice()).isZero(); + assertThat(result.getBrokerageSummary().getPendingPrice()).isZero(); + assertThat(result.getBrokeragePrice()).isZero(); + } + + @Test + void childPageLoadsOnlyPageMembersAndUsesLoginMemberAsContributionRecipient() { + AppBrokerageUserChildSummaryRespVO child = new AppBrokerageUserChildSummaryRespVO() + .setId(374L).setBrokeragePrice(75L).setBrokerageOrderCount(2).setBrokerageUserCount(13); + AppBrokerageUserChildSummaryRespVO noIncome = new AppBrokerageUserChildSummaryRespVO().setId(465L); + AppBrokerageUserChildSummaryPageReqVO query = new AppBrokerageUserChildSummaryPageReqVO(); + query.setLevel(2); + when(brokerageUserService.getBrokerageUserChildSummaryPage(query, 300L)).thenReturn( + new PageResult<>(Arrays.asList(child, noIncome), 13L)); + when(brokerageRecordService.getIncomeSummaryMap(anyCollection())).thenReturn( + Collections.singletonMap(374L, summary(3000000000L, 200L))); + when(brokerageRecordService.getContributionSummaryMap(eq(300L), anyCollection())).thenReturn( + Collections.singletonMap(374L, summary(3510L, 10L))); + + PageResult result = userController.getBrokerageUserChildSummaryPage(query).getData(); + + assertThat(result.getTotal()).isEqualTo(13L); + assertThat(child.getBrokerageSummary().getSettledPrice()).isEqualTo(3000000000L); + assertThat(child.getContributionSummary().getSettledPrice()).isEqualTo(3510L); + assertThat(child.getContributionSummary().getPendingPrice()).isEqualTo(10L); + assertThat(child.getBrokeragePrice()).isEqualTo(75L); + assertThat(JsonUtils.toJsonString(child)).contains("\"brokeragePrice\":75"); + assertThat(child.getBrokerageOrderCount()).isEqualTo(2); + assertThat(noIncome.getBrokerageSummary().getSettledPrice()).isZero(); + assertThat(noIncome.getContributionSummary().getPendingPrice()).isZero(); + verify(brokerageRecordService).getIncomeSummaryMap(new HashSet<>(Arrays.asList(374L, 465L))); + verify(brokerageRecordService).getContributionSummaryMap(300L, new HashSet<>(Arrays.asList(374L, 465L))); + verifyNoMoreInteractions(brokerageRecordService); + } + + @Test + void emptyChildPagePreservesTotalAndDoesNotLoadIncome() { + AppBrokerageUserChildSummaryPageReqVO query = new AppBrokerageUserChildSummaryPageReqVO(); + query.setLevel(1); + query.setPageNo(9); + when(brokerageUserService.getBrokerageUserChildSummaryPage(query, 300L)).thenReturn( + new PageResult<>(Collections.emptyList(), 43L)); + PageResult result = userController.getBrokerageUserChildSummaryPage(query).getData(); + assertThat(result.getList()).isEmpty(); + assertThat(result.getTotal()).isEqualTo(43L); + verifyNoInteractions(brokerageRecordService); + } + + @Test + void incomeRecordPageForcesCurrentMemberAndReturnsIncomeMetadata() { + AppBrokerageRecordPageReqVO query = new AppBrokerageRecordPageReqVO(); + query.setIncomeOnly(true); + query.setStatus(1); + query.setPageNo(2); + query.setPageSize(20); + LocalDateTime time = LocalDateTime.of(2026, 11, 2, 18, 39, 32); + BrokerageRecordDO record = new BrokerageRecordDO().setId(15).setUserId(300L).setSourceUserId(465L) + .setBizId("9").setBizType(2).setTitle("会员佣金").setDescription("获得会员佣金 35.10") + .setPrice(3510).setStatus(1).setUnfreezeTime(time); + record.setCreateTime(time); + when(brokerageRecordService.getBrokerageRecordPage(any())).thenReturn( + new PageResult<>(Collections.singletonList(record), 25L)); + + PageResult page = recordController.getBrokerageRecordPage(query).getData(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BrokerageRecordPageReqVO.class); + verify(brokerageRecordService).getBrokerageRecordPage(captor.capture()); + assertThat(captor.getValue().getUserId()).isEqualTo(300L); + assertThat(captor.getValue().getIncomeOnly()).isTrue(); + assertThat(captor.getValue().getStatus()).isEqualTo(1); + assertThat(captor.getValue().getPageNo()).isEqualTo(2); + assertThat(captor.getValue().getPageSize()).isEqualTo(20); + assertThat(page.getTotal()).isEqualTo(25L); + AppBrokerageRecordRespVO result = page.getList().get(0); + assertThat(result.getBizType()).isEqualTo(2); + assertThat(result.getDescription()).isEqualTo("获得会员佣金 35.10"); + assertThat(result.getSourceUserId()).isEqualTo(465L); + assertThat(result.getUnfreezeTime()).isEqualTo(time); + assertThat(result.getPrice()).isEqualTo(3510); + assertThat(result.getCreateTime()).isEqualTo(time); + } + + @Test + void existingRecordRequestsKeepOptionalIncomeFilterUnset() { + AppBrokerageRecordPageReqVO query = new AppBrokerageRecordPageReqVO(); + query.setBizType(3); + BrokerageRecordPageReqVO result = BrokerageRecordConvert.INSTANCE.convert(query, 300L); + assertThat(result.getIncomeOnly()).isNull(); + assertThat(result.getBizType()).isEqualTo(3); + assertThat(result.getUserId()).isEqualTo(300L); + } + + @Test + void httpBindsTeamSortingAndRejectsInvalidFieldsDirectionsAndLevels() throws Exception { + when(brokerageUserService.getBrokerageUserChildSummaryPage(any(), eq(300L))).thenReturn(PageResult.empty()); + MockMvc mvc = MockMvcBuilders.standaloneSetup(userController).build(); + mvc.perform(get("/trade/brokerage-user/child-summary-page").param("level", "1") + .param("sortingField.field", "contributionPrice").param("sortingField.order", "asc") + .param("pageNo", "2").param("pageSize", "3")) + .andExpect(status().isOk()); + mvc.perform(get("/trade/brokerage-user/child-summary-page").param("level", "2") + .param("sortingField.field", "incomePrice").param("sortingField.order", "DESC")) + .andExpect(status().isOk()); + mvc.perform(get("/trade/brokerage-user/child-summary-page").param("level", "1") + .param("sortingField.field", "price").param("sortingField.order", "Asc")) + .andExpect(status().isOk()); + mvc.perform(get("/trade/brokerage-user/child-summary-page").param("level", "1")) + .andExpect(status().isOk()); + mvc.perform(get("/trade/brokerage-user/child-summary-page").param("level", "1") + .param("sortingField.field", "incomePrice").param("sortingField.order", "desc; SELECT 1")) + .andExpect(status().isBadRequest()); + mvc.perform(get("/trade/brokerage-user/child-summary-page").param("level", "1") + .param("sortingField.field", "incomePrice DESC, bu.id").param("sortingField.order", "asc")) + .andExpect(status().isBadRequest()); + mvc.perform(get("/trade/brokerage-user/child-summary-page").param("level", "1") + .param("sortingField.field", "contributionPrice")) + .andExpect(status().isBadRequest()); + mvc.perform(get("/trade/brokerage-user/child-summary-page").param("level", "3")) + .andExpect(status().isBadRequest()); + mvc.perform(get("/trade/brokerage-user/child-summary-page")) + .andExpect(status().isBadRequest()); + verify(brokerageUserService).getBrokerageUserChildSummaryPage(argThat(query -> + query.getLevel() == 1 && query.getPageNo() == 2 && query.getPageSize() == 3 + && "contributionPrice".equals(query.getSortingField().getField()) + && "asc".equals(query.getSortingField().getOrder())), eq(300L)); + verify(brokerageUserService, times(4)).getBrokerageUserChildSummaryPage(any(), eq(300L)); + } + + private static BrokerageIncomeSummaryRespDTO summary(Long settledPrice, Long pendingPrice) { + return new BrokerageIncomeSummaryRespDTO().setSettledPrice(settledPrice).setPendingPrice(pendingPrice); + } +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/AppBrokerageTeamQueryTest.java b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/AppBrokerageTeamQueryTest.java new file mode 100644 index 0000000..d2746ce --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/AppBrokerageTeamQueryTest.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.trade.service.brokerage; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.member.api.user.MemberUserApi; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; +import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserChildSummaryPageReqVO; +import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserChildSummaryRespVO; +import cn.iocoder.yudao.module.trade.dal.mysql.brokerage.BrokerageUserMapper; +import cn.iocoder.yudao.module.trade.service.config.TradeConfigService; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class AppBrokerageTeamQueryTest extends BaseMockitoUnitTest { + + @InjectMocks private BrokerageUserServiceImpl service; + @Mock private BrokerageUserMapper mapper; + @Mock private MemberUserApi memberUserApi; + @Mock private TradeConfigService tradeConfigService; + + @Test + void omittedAndBlankNicknamesKeepAllExistingMembers() { + when(mapper.selectIdListByBindUserIdIn(Collections.singleton(300L))).thenReturn(Arrays.asList(374L, 465L)); + when(memberUserApi.getUserList(Arrays.asList(374L, 465L))).thenReturn(Arrays.asList( + new MemberUserRespDTO().setId(374L).setNickname("甲"), + new MemberUserRespDTO().setId(465L).setNickname(null))); + when(mapper.selectSummaryPageByUserId(any(), eq(1), eq(1), anyCollection(), isNull(), eq(300L))).thenReturn( + new Page(1, 10, 2).setRecords(Arrays.asList( + new AppBrokerageUserChildSummaryRespVO().setId(374L), + new AppBrokerageUserChildSummaryRespVO().setId(465L)))); + AppBrokerageUserChildSummaryPageReqVO query = new AppBrokerageUserChildSummaryPageReqVO(); + query.setLevel(1); + + assertThat(service.getBrokerageUserChildSummaryPage(query, 300L).getTotal()).isEqualTo(2); + query.setNickname(" "); + assertThat(service.getBrokerageUserChildSummaryPage(query, 300L).getList()).hasSize(2); + verify(mapper, times(2)).selectSummaryPageByUserId(any(), eq(1), eq(1), + eq(new HashSet<>(Arrays.asList(374L, 465L))), isNull(), eq(300L)); + } + + @Test + void nicknameSearchFiltersBeforePaginationAndKeepsEmptyPageTotal() { + when(mapper.selectIdListByBindUserIdIn(Collections.singleton(300L))).thenReturn(Arrays.asList(374L, 465L)); + when(memberUserApi.getUserList(Arrays.asList(374L, 465L))).thenReturn(Arrays.asList( + new MemberUserRespDTO().setId(374L).setNickname("小甲"), + new MemberUserRespDTO().setId(465L).setNickname("乙"))); + when(mapper.selectSummaryPageByUserId(any(), eq(1), eq(1), eq(Collections.singleton(374L)), isNull(), eq(300L))).thenReturn( + new Page(2, 10, 1).setRecords(Collections.emptyList())); + AppBrokerageUserChildSummaryPageReqVO query = new AppBrokerageUserChildSummaryPageReqVO(); + query.setLevel(1); + query.setNickname("甲"); + query.setPageNo(2); + + PageResult result = service.getBrokerageUserChildSummaryPage(query, 300L); + + assertThat(result.getList()).isEmpty(); + assertThat(result.getTotal()).isEqualTo(1L); + verify(mapper).selectSummaryPageByUserId(any(), eq(1), eq(1), eq(Collections.singleton(374L)), isNull(), eq(300L)); + } + + @Test + void unknownNicknameNeverFallsBackToAllTeamMembers() { + when(mapper.selectIdListByBindUserIdIn(Collections.singleton(300L))).thenReturn(Collections.singletonList(374L)); + when(memberUserApi.getUserList(Collections.singletonList(374L))).thenReturn( + Collections.singletonList(new MemberUserRespDTO().setId(374L).setNickname("甲"))); + AppBrokerageUserChildSummaryPageReqVO query = new AppBrokerageUserChildSummaryPageReqVO(); + query.setLevel(1); + query.setNickname("无匹配"); + + assertThat(service.getBrokerageUserChildSummaryPage(query, 300L).getTotal()).isZero(); + verify(mapper, never()).selectSummaryPageByUserId(any(), anyInt(), anyInt(), anyCollection(), any(), anyLong()); + } +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/AppBrokerageTeamSortingTest.java b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/AppBrokerageTeamSortingTest.java new file mode 100644 index 0000000..ddce0c7 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/AppBrokerageTeamSortingTest.java @@ -0,0 +1,255 @@ +package cn.iocoder.yudao.module.trade.service.brokerage; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.pojo.SortingField; +import cn.iocoder.yudao.framework.tenant.config.TenantProperties; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.tenant.core.db.TenantDatabaseInterceptor; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.member.api.user.MemberUserApi; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; +import cn.iocoder.yudao.module.product.api.sku.ProductSkuApi; +import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi; +import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO; +import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserChildSummaryPageReqVO; +import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserChildSummaryRespVO; +import cn.iocoder.yudao.module.trade.service.config.TradeConfigService; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.jdbc.Sql; + +import javax.annotation.Resource; +import javax.sql.DataSource; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.Mockito.when; + +@Import({BrokerageUserServiceImpl.class, BrokerageRecordServiceImpl.class}) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:brokerage_team_sorting;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value;", + "spring.sql.init.schema-locations=classpath:/sql/brokerage_relationship_schema.sql,classpath:/sql/brokerage_income_schema.sql" +}) +@Sql(statements = {"DELETE FROM trade_brokerage_record", "DELETE FROM trade_brokerage_user"}, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) +class AppBrokerageTeamSortingTest extends BaseDbUnitTest { + + @Resource private BrokerageUserServiceImpl service; + @Resource private BrokerageRecordServiceImpl recordService; + @Resource private MybatisPlusInterceptor interceptor; + @Resource private DataSource dataSource; + @MockBean private MemberUserApi memberUserApi; + @MockBean private TradeConfigService tradeConfigService; + @MockBean private ProductSpuApi productSpuApi; + @MockBean private ProductSkuApi productSkuApi; + + private JdbcTemplate jdbc; + private List previousInterceptors; + private final Map members = new HashMap<>(); + + @BeforeEach + void seedTeamAndEnableTenantInterceptor() { + jdbc = new JdbcTemplate(dataSource); + previousInterceptors = new ArrayList<>(interceptor.getInterceptors()); + interceptor.setInterceptors(Arrays.asList( + new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(new TenantProperties())), + new PaginationInnerInterceptor())); + TenantContextHolder.setTenantId(1L); + node(1, null, "根会员", false, 1); + node(2, 1L, "目标甲", false, 1); + node(3, 1L, "目标乙", false, 1); + node(4, 1L, "会员四", false, 1); + node(5, 1L, "会员五", false, 1); + node(6, 1L, "大额会员", false, 1); + node(7, 1L, "零收益会员", false, 1); + node(8, 1L, "已删除会员", true, 1); + node(9, 1L, "其他租户会员", false, 2); + node(10, 2L, "目标甲二级", false, 1); + node(11, 2L, "目标乙二级", false, 1); + node(12, 3L, "普通二级", false, 1); + when(memberUserApi.getUserList(anyCollection())).thenAnswer(invocation -> { + Collection ids = invocation.getArgument(0); + return ids.stream().map(members::get).filter(Objects::nonNull).collect(Collectors.toList()); + }); + + income(2, 100, 1, 50); + income(2, 101, 2, 50); + income(3, 100, 1, 200); + income(3, 101, 2, 300); + income(5, 100, 1, 100); + income(6, 100, 1, Integer.MAX_VALUE); + income(6, 101, 2, Integer.MAX_VALUE); + income(1, 2, 1, 900); + income(1, 2, 2, 100); + income(1, 3, 1, 200); + income(1, 4, 2, 700); + income(1, 5, 2, 200); + income(1, 6, 1, 3000); + income(10, 100, 1, 700); + income(11, 100, 2, 100); + income(1, 10, 2, 50); + income(1, 11, 1, 200); + // 二级节点给其直接上级的收益不能混入它为根会员产生的贡献。 + income(2, 10, 1, 100); + } + + @AfterEach + void restoreInterceptorsAndTenantContext() { + interceptor.setInterceptors(previousInterceptors); + TenantContextHolder.clear(); + } + + @Test + void contributionSortRunsBeforePaginationInBothDirectionsAndUsesDescendingIdsForTies() { + assertPages("contributionPrice", SortingField.ORDER_DESC, 6L, 2L, 4L, 5L, 3L, 7L); + assertPages("contributionPrice", SortingField.ORDER_ASC, 7L, 5L, 3L, 4L, 2L, 6L); + } + + @Test + void personalIncomeSortIncludesMemberCommissionUsesLongAndDiffersFromContribution() { + // 节点 2 还从二级节点 10 获得 100 分,因此本人累计为 200 分。 + assertPages("incomePrice", SortingField.ORDER_DESC, 6L, 3L, 2L, 5L, 7L, 4L); + assertPages("incomePrice", SortingField.ORDER_ASC, 7L, 4L, 5L, 2L, 3L, 6L); + Map summaries = recordService.getIncomeSummaryMap(Arrays.asList(2L, 6L)); + assertThat(summaries.get(2L).getSettledPrice()).isEqualTo(200L); + assertThat(summaries.get(6L).getSettledPrice()).isEqualTo(2L * Integer.MAX_VALUE); + } + + @Test + void invalidIncomeAndOtherTenantsCannotChangeAmountOrderOrReturnedTotals() { + excludedIncome(1, 7); + excludedIncome(7, 100); + income(99, 7, 1, 1000000); + income(1, 8, 1, 1000000); + income(1, 9, 1, 1000000); + assertPages("contributionPrice", SortingField.ORDER_DESC, 6L, 2L, 4L, 5L, 3L, 7L); + assertPages("incomePrice", SortingField.ORDER_DESC, 6L, 3L, 2L, 5L, 7L, 4L); + assertThat(recordService.getContributionSummaryMap(1L, Arrays.asList(7L)).get(7L).getSettledPrice()).isZero(); + assertThat(recordService.getIncomeSummaryMap(Arrays.asList(7L)).get(7L).getSettledPrice()).isZero(); + } + + @Test + void nicknameAndLevelFiltersApplyBeforeSortingAndSecondLevelUsesTheLoggedInRoot() { + AppBrokerageUserChildSummaryPageReqVO query = query("contributionPrice", SortingField.ORDER_DESC); + query.setNickname("目标"); + query.setPageSize(1); + assertThat(service.getBrokerageUserChildSummaryPage(query, 1L).getList()) + .extracting(AppBrokerageUserChildSummaryRespVO::getId).containsExactly(2L); + query.setPageNo(2); + assertThat(service.getBrokerageUserChildSummaryPage(query, 1L).getList()) + .extracting(AppBrokerageUserChildSummaryRespVO::getId).containsExactly(3L); + + query.setLevel(2); + query.setPageNo(1); + PageResult secondLevel = service.getBrokerageUserChildSummaryPage(query, 1L); + assertThat(secondLevel.getTotal()).isEqualTo(2); + assertThat(secondLevel.getList()).extracting(AppBrokerageUserChildSummaryRespVO::getId).containsExactly(11L); + query.setSortingField(new SortingField("incomePrice", SortingField.ORDER_DESC)); + assertThat(service.getBrokerageUserChildSummaryPage(query, 1L).getList()) + .extracting(AppBrokerageUserChildSummaryRespVO::getId).containsExactly(10L); + query.setNickname("不存在"); + assertThat(service.getBrokerageUserChildSummaryPage(query, 1L).getTotal()).isZero(); + } + + @Test + void legacyUserCountOrderCountAndPriceSortsRemainAvailable() { + jdbc.update("DELETE FROM trade_brokerage_record"); + income(2, 100, 1, 100); + income(2, 101, 1, 200); + income(3, 100, 1, 50); + income(4, 100, 2, 1000000); + for (String field : Arrays.asList("userCount", "orderCount", "price")) { + AppBrokerageUserChildSummaryPageReqVO query = query(field, SortingField.ORDER_DESC); + PageResult result = service.getBrokerageUserChildSummaryPage(query, 1L); + assertThat(result.getTotal()).isEqualTo(6); + assertThat(result.getList()).extracting(AppBrokerageUserChildSummaryRespVO::getId).containsExactly(2L, 3L); + } + AppBrokerageUserChildSummaryRespVO first = service.getBrokerageUserChildSummaryPage( + query("price", SortingField.ORDER_DESC), 1L).getList().get(0); + assertThat(first.getBrokeragePrice()).isEqualTo(300L); + assertThat(first.getBrokerageOrderCount()).isEqualTo(2); + assertThat(first.getBrokerageUserCount()).isEqualTo(2); + } + + @Test + void directServiceCallsUseAFixedDirectionAndDefaultOrderingIsStable() { + assertPages("contributionPrice", "desc; DELETE FROM trade_brokerage_record", + 6L, 2L, 4L, 5L, 3L, 7L); + AppBrokerageUserChildSummaryPageReqVO query = query("incomePrice", SortingField.ORDER_ASC); + query.setSortingField(null); + assertThat(service.getBrokerageUserChildSummaryPage(query, 1L).getList()) + .extracting(AppBrokerageUserChildSummaryRespVO::getId).containsExactly(7L, 6L); + } + + private void assertPages(String field, String direction, Long... expectedIds) { + AppBrokerageUserChildSummaryPageReqVO query = query(field, direction); + List actualIds = new ArrayList<>(); + for (int pageNo = 1; pageNo <= 3; pageNo++) { + query.setPageNo(pageNo); + PageResult page = service.getBrokerageUserChildSummaryPage(query, 1L); + assertThat(page.getTotal()).isEqualTo(6); + assertThat(page.getList()).hasSize(2); + actualIds.addAll(page.getList().stream().map(AppBrokerageUserChildSummaryRespVO::getId).collect(Collectors.toList())); + } + assertThat(actualIds).containsExactly(expectedIds); + query.setPageNo(4); + PageResult beyond = service.getBrokerageUserChildSummaryPage(query, 1L); + assertThat(beyond.getTotal()).isEqualTo(6); + assertThat(beyond.getList()).isEmpty(); + } + + private AppBrokerageUserChildSummaryPageReqVO query(String field, String direction) { + AppBrokerageUserChildSummaryPageReqVO query = new AppBrokerageUserChildSummaryPageReqVO(); + query.setLevel(1); + query.setPageSize(2); + query.setSortingField(new SortingField(field, direction)); + return query; + } + + private void node(long id, Long parentId, String nickname, boolean deleted, long tenantId) { + jdbc.update("INSERT INTO trade_brokerage_user (id, bind_user_id, bind_user_time, deleted, tenant_id)" + + " VALUES (?, ?, '2026-11-01 12:00:00', ?, ?)", id, parentId, deleted, tenantId); + members.put(id, new MemberUserRespDTO().setId(id).setNickname(nickname)); + } + + private void income(long recipientId, long sourceId, int type, int price) { + addIncome(recipientId, sourceId, type, price, 1, "分销收益", "有效佣金", false, 1); + } + + private void excludedIncome(long recipientId, long sourceId) { + addIncome(recipientId, sourceId, 3, 1000000, 1, "提现返还", "返还", false, 1); + addIncome(recipientId, sourceId, 4, 1000000, 1, "驳回返还", "返还", false, 1); + addIncome(recipientId, sourceId, 2, -1000000, 1, "会员佣金", "负金额", false, 1); + addIncome(recipientId, sourceId, 2, 1000000, 0, "会员佣金", "待结算", false, 1); + addIncome(recipientId, sourceId, 1, 1000000, 2, "推广佣金", "已取消", false, 1); + addIncome(recipientId, sourceId, 1, 1000000, 1, "推广佣金", "已删除", true, 1); + addIncome(recipientId, sourceId, 1, 1000000, 1, "推广佣金", "其他租户", false, 2); + addIncome(recipientId, sourceId, 2, 1000000, 1, "历史提现", "返还", false, 1); + addIncome(recipientId, sourceId, 1, 1000000, 1, "历史记录", "提现返还", false, 1); + } + + private void addIncome(long recipientId, long sourceId, int type, int price, int status, + String title, String description, boolean deleted, long tenantId) { + jdbc.update("INSERT INTO trade_brokerage_record" + + " (user_id, source_user_id, biz_type, price, status, title, description, deleted, tenant_id)" + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + recipientId, sourceId, type, price, status, title, description, deleted, tenantId); + } +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageIncomeQueryTest.java b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageIncomeQueryTest.java index 79dad65..0d9dcd8 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageIncomeQueryTest.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageIncomeQueryTest.java @@ -5,6 +5,8 @@ import cn.iocoder.yudao.framework.tenant.config.TenantProperties; import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; import cn.iocoder.yudao.framework.tenant.core.db.TenantDatabaseInterceptor; import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.member.api.user.MemberUserApi; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; import cn.iocoder.yudao.module.product.api.sku.ProductSkuApi; import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi; import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO; @@ -37,6 +39,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import static org.mockito.Mockito.when; @Import(BrokerageRecordServiceImpl.class) @TestPropertySource(properties = { @@ -54,6 +57,7 @@ class BrokerageIncomeQueryTest extends BaseDbUnitTest { @MockBean private BrokerageUserService brokerageUserService; @MockBean private ProductSpuApi productSpuApi; @MockBean private ProductSkuApi productSkuApi; + @MockBean private MemberUserApi memberUserApi; private JdbcTemplate jdbc; @@ -71,6 +75,32 @@ class BrokerageIncomeQueryTest extends BaseDbUnitTest { TenantContextHolder.clear(); } + @Test + void usernameMatchesAllRecipientsBeforePaginationAndKeepsExactFilters() { + when(memberUserApi.getUserListByNickname("张")).thenReturn(Arrays.asList( + new MemberUserRespDTO().setId(1L).setNickname("小张"), + new MemberUserRespDTO().setId(3L).setNickname("张三"))); + add(1, 2, 1, 1, 100, "订单佣金", "获得推广佣金", false, 1); + add(2, 2, 1, 1, 200, "订单佣金", "获得推广佣金", false, 1); + add(3, 2, 1, 1, 300, "订单佣金", "获得推广佣金", false, 1); + BrokerageRecordPageReqVO query = incomeQuery(null, 2L); + query.setUserNickname(" 张 "); + query.setPageSize(1); + + PageResult page = service.getBrokerageRecordPage(query); + assertThat(page.getTotal()).isEqualTo(2); + assertThat(page.getList()).extracting(BrokerageRecordDO::getUserId).containsExactly(3L); + query.setPageNo(2); + assertThat(service.getBrokerageRecordPage(query).getList()) + .extracting(BrokerageRecordDO::getUserId).containsExactly(1L); + query.setPageNo(1); + query.setUserId(2L); + assertThat(service.getBrokerageRecordPage(query).getTotal()).isZero(); + query.setUserId(1L); + query.setSourceUserId(9L); + assertThat(service.getBrokerageRecordPage(query).getTotal()).isZero(); + } + @Test void defaultsAndEmptyInputsReturnZeroSummaries() { assertThat(new BrokerageIncomeSummaryRespDTO().getSettledPrice()).isZero(); diff --git a/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserNicknameQueryTest.java b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserNicknameQueryTest.java new file mode 100644 index 0000000..5e885fa --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserNicknameQueryTest.java @@ -0,0 +1,88 @@ +package cn.iocoder.yudao.module.trade.service.brokerage; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.member.api.user.MemberUserApi; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; +import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.record.BrokerageRecordPageReqVO; +import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.withdraw.BrokerageWithdrawPageReqVO; +import cn.iocoder.yudao.module.trade.dal.mysql.brokerage.BrokerageRecordMapper; +import cn.iocoder.yudao.module.trade.dal.mysql.brokerage.BrokerageWithdrawMapper; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class BrokerageUserNicknameQueryTest extends BaseMockitoUnitTest { + + @InjectMocks private BrokerageRecordServiceImpl recordService; + @InjectMocks private BrokerageWithdrawServiceImpl withdrawService; + @Mock private BrokerageRecordMapper recordMapper; + @Mock private BrokerageWithdrawMapper withdrawMapper; + @Mock private MemberUserApi memberUserApi; + + @Test + void blankUsernameKeepsExistingIdQueriesWithoutMemberLookup() { + BrokerageRecordPageReqVO recordQuery = new BrokerageRecordPageReqVO(); + recordQuery.setUserId(1L); + recordQuery.setUserNickname(" "); + BrokerageWithdrawPageReqVO withdrawQuery = new BrokerageWithdrawPageReqVO(); + withdrawQuery.setUserId(1L); + withdrawQuery.setUserNickname("\t "); + + recordService.getBrokerageRecordPage(recordQuery); + withdrawService.getBrokerageWithdrawPage(withdrawQuery); + + verify(recordMapper).selectPage(recordQuery, (Collection) null); + verify(withdrawMapper).selectPage(withdrawQuery, (Collection) null); + verifyNoInteractions(memberUserApi); + assertThat(recordQuery.getUserId()).isEqualTo(1L); + assertThat(withdrawQuery.getUserId()).isEqualTo(1L); + } + + @Test + void unknownUsernameReturnsEmptyPagesWithoutQueryingAllRecords() { + when(memberUserApi.getUserListByNickname("无人匹配")).thenReturn(Collections.emptyList()); + BrokerageRecordPageReqVO recordQuery = new BrokerageRecordPageReqVO(); + recordQuery.setUserNickname(" 无人匹配 "); + BrokerageWithdrawPageReqVO withdrawQuery = new BrokerageWithdrawPageReqVO(); + withdrawQuery.setUserNickname(" 无人匹配 "); + + assertThat(recordService.getBrokerageRecordPage(recordQuery).getTotal()).isZero(); + assertThat(recordService.getBrokerageRecordPage(recordQuery).getList()).isEmpty(); + assertThat(withdrawService.getBrokerageWithdrawPage(withdrawQuery).getTotal()).isZero(); + assertThat(withdrawService.getBrokerageWithdrawPage(withdrawQuery).getList()).isEmpty(); + verifyNoInteractions(recordMapper, withdrawMapper); + } + + @Test + void withdrawalExportFiltersEveryUserSharingTheMatchingNickname() { + when(memberUserApi.getUserListByNickname("张")).thenReturn(Arrays.asList( + new MemberUserRespDTO().setId(1L).setNickname("小张"), + new MemberUserRespDTO().setId(2L).setNickname("小张"))); + BrokerageWithdrawPageReqVO query = new BrokerageWithdrawPageReqVO(); + query.setUserNickname(" 张 "); + query.setAccountNo("alipay@example.com"); + query.setStatus(0); + query.setPageSize(PageParam.PAGE_SIZE_NONE); + when(withdrawMapper.selectPage(query, new HashSet<>(Arrays.asList(1L, 2L)))) + .thenReturn(PageResult.empty()); + + withdrawService.getBrokerageWithdrawPage(query); + + verify(withdrawMapper).selectPage(query, new HashSet<>(Arrays.asList(1L, 2L))); + assertThat(query.getPageSize()).isEqualTo(PageParam.PAGE_SIZE_NONE); + assertThat(query.getAccountNo()).isEqualTo("alipay@example.com"); + assertThat(query.getStatus()).isZero(); + } +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserRelationshipTest.java b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserRelationshipTest.java index 430acfe..6b925c8 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserRelationshipTest.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserRelationshipTest.java @@ -2,9 +2,13 @@ package cn.iocoder.yudao.module.trade.service.brokerage; import cn.iocoder.yudao.framework.common.pojo.PageParam; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.pojo.SortingField; import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; import cn.iocoder.yudao.module.member.api.user.MemberUserApi; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageUserRespDTO; +import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserChildSummaryPageReqVO; +import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserChildSummaryRespVO; import cn.iocoder.yudao.module.trade.dal.mysql.brokerage.BrokerageUserMapper; import cn.iocoder.yudao.module.trade.service.config.TradeConfigService; import org.junit.jupiter.api.BeforeEach; @@ -22,13 +26,16 @@ import java.util.Collections; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.anyCollection; @Import(BrokerageUserServiceImpl.class) @TestPropertySource(properties = { "spring.datasource.url=jdbc:h2:mem:brokerage_relationship;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value;", - "spring.sql.init.schema-locations=classpath:/sql/brokerage_relationship_schema.sql" + "spring.sql.init.schema-locations=classpath:/sql/brokerage_relationship_schema.sql,classpath:/sql/brokerage_income_schema.sql" }) -@Sql(statements = "DELETE FROM trade_brokerage_user", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) +@Sql(statements = {"DELETE FROM trade_brokerage_record", "DELETE FROM trade_brokerage_user"}, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) class BrokerageUserRelationshipTest extends BaseDbUnitTest { @Resource private BrokerageUserServiceImpl service; @@ -89,4 +96,30 @@ class BrokerageUserRelationshipTest extends BaseDbUnitTest { assertThat(service.getBrokerageUserList(Collections.emptyList())).isEmpty(); assertThat(mapper.selectDirectChildCountMap(Collections.emptyList())).isEmpty(); } + + @Test + void appTeamSqlMapsLegacyIncomeBeyondIntegerRangeWithoutChangingItsMeaning() { + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + jdbc.update("INSERT INTO trade_brokerage_record (user_id, biz_type, price, status)" + + " VALUES (25, 1, ?, 1), (25, 1, ?, 1), (25, 2, 333, 1)," + + " (25, 1, 700, 0), (25, 1, 800, 2)", Integer.MAX_VALUE, Integer.MAX_VALUE); + jdbc.update("INSERT INTO trade_brokerage_record (user_id, biz_type, price, status, deleted)" + + " VALUES (25, 1, 900, 1, TRUE)"); + when(memberUserApi.getUserList(anyCollection())).thenReturn(Collections.singletonList( + new MemberUserRespDTO().setId(25L).setNickname("大额会员"))); + AppBrokerageUserChildSummaryPageReqVO query = new AppBrokerageUserChildSummaryPageReqVO(); + query.setLevel(1); + query.setSortingField(new SortingField("price", SortingField.ORDER_DESC)); + + PageResult page = service.getBrokerageUserChildSummaryPage(query, 1L); + + assertThat(page.getTotal()).isEqualTo(1L); + assertThat(page.getList()).singleElement().satisfies(user -> { + assertThat(user.getId()).isEqualTo(25L); + assertThat(user.getNickname()).isEqualTo("大额会员"); + assertThat(user.getBrokeragePrice()).isEqualTo(2L * Integer.MAX_VALUE); + assertThat(user.getBrokerageOrderCount()).isEqualTo(2); + assertThat(user.getBrokerageUserCount()).isEqualTo(2); + }); + } } diff --git a/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java index 2708a28..c181137 100644 --- a/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java +++ b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java @@ -114,4 +114,23 @@ public interface MemberErrorCode { ErrorCode RIDE_PLAN_NOT_JOINED = new ErrorCode(1_004_020_004, "尚未加入该发车局"); ErrorCode RIDE_TAG_NOT_EXISTS = new ErrorCode(1_004_020_005, "骑行标签不存在或已停用"); ErrorCode RIDE_RECORD_NOT_EXISTS = new ErrorCode(1_004_020_006, "骑行战报不存在或已下架"); + // ========== App 版本发布 1-004-021-000 ========== + /** 版本不存在、已删除或属于其他租户。 */ + ErrorCode APP_VERSION_NOT_EXISTS = new ErrorCode(1_004_021_000, "版本记录不存在"); + /** 引用的可信包不存在。 */ + ErrorCode APP_VERSION_PACKAGE_NOT_EXISTS = new ErrorCode(1_004_021_001, "已校验安装包不存在,请重新上传"); + /** 包结构、平台、AppID 或版本元数据不符合要求。 */ + ErrorCode APP_VERSION_PACKAGE_INVALID = new ErrorCode(1_004_021_002, "安装包校验失败:{}"); + /** 上传或存储失败,管理员可以重新上传。 */ + ErrorCode APP_VERSION_UPLOAD_FAILED = new ErrorCode(1_004_021_003, "安装包上传失败,请检查文件存储配置后重试"); + /** 版本行锁检查到当前状态不允许此操作。 */ + ErrorCode APP_VERSION_STATE_INVALID = new ErrorCode(1_004_021_004, "当前版本状态不支持此操作,编辑或删除前请先下架"); + /** 灰测名单为空或包含无效会员。 */ + ErrorCode APP_VERSION_MEMBERS_INVALID = new ErrorCode(1_004_021_005, "灰测名单不能为空,请检查已删除或禁用的会员"); + /** 同平台、包类型和版本码只能维护一条有效发布记录。 */ + ErrorCode APP_VERSION_DUPLICATE = new ErrorCode(1_004_021_006, "该平台和包类型的版本码已存在,请编辑现有记录"); + /** WGT 必须填写有效的基座兼容范围。 */ + ErrorCode APP_VERSION_NATIVE_RANGE_INVALID = new ErrorCode(1_004_021_007, "请填写 WGT 最低原生版本码,最高版本码不能小于最低版本码"); + /** 发布目标为空、无效或与原生包类型不兼容。 */ + ErrorCode APP_VERSION_PLATFORMS_INVALID = new ErrorCode(1_004_021_008, "请选择有效的发布平台,APK 仅支持 Android"); } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApi.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApi.java index 1614059..f81775b 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApi.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApi.java @@ -12,6 +12,8 @@ import java.util.Map; public interface ChatApi { + ChatPrivatePushContextRespVO privatePushContext(Long messageId); + /* *************************************** start 用户相关 start *************************************** */ /** diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApiImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApiImpl.java index 1f800ca..3c27863 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApiImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApiImpl.java @@ -38,6 +38,27 @@ public class ChatApiImpl implements ChatApi { @Resource private ChatProperties chatProperties; + @Override + public ChatPrivatePushContextRespVO privatePushContext(Long messageId) { + String body = JSONUtil.toJsonStr(Collections.singletonMap("messageId", messageId)); + String url = chatProperties.getBaseUrl() + "/third/privateMsg/pushContext"; + try (HttpResponse response = HttpRequest.post(url).header(apiCountersign(body)) + .contentType("application/json").body(body).timeout(5000).execute()) { + if (!response.isOk() || !JSONUtil.isTypeJSONObject(response.body())) { + throw new IllegalStateException("IM 推送上下文请求失败,HTTP " + response.getStatus()); + } + ChatBaseRespVO result = JSONUtil.toBean(response.body(), ChatBaseRespVO.class); + if (result == null || !result.isSuccess()) { + throw new IllegalStateException("IM 推送上下文查询失败"); + } + ChatPrivatePushContextRespVO context = JSONUtil.toBean(result.getData(), ChatPrivatePushContextRespVO.class); + if (context == null || context.getEligible() == null) { + throw new IllegalStateException("IM 推送上下文响应不完整"); + } + return context; + } + } + /* *************************************** start 用户相关 start *************************************** */ /** diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivatePushContextRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivatePushContextRespVO.java new file mode 100644 index 0000000..af683f3 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivatePushContextRespVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.member.client.vo.chat.privatemsg; + +import lombok.Data; +import java.util.List; + +@Data +public class ChatPrivatePushContextRespVO { + private Long messageId; + private Long sendId; + private Long recvId; + private Integer type; + private String content; + private Long sendTime; + private Integer status; + private Boolean eligible; + private String skipReason; + private List foregroundDevices; + + @Data + public static class ForegroundDevice { + private String registrationId; + private String sessionToken; + private Long heartbeatVersion; + private Long leaseExpiresAt; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/AppVersionController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/AppVersionController.java new file mode 100644 index 0000000..d8a5221 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/AppVersionController.java @@ -0,0 +1,131 @@ +package cn.iocoder.yudao.module.member.controller.admin.appversion; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.member.controller.admin.appversion.vo.*; +import cn.iocoder.yudao.module.member.service.appversion.AppVersionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.Data; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import javax.annotation.Resource; +import javax.validation.Valid; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Positive; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** 后台版本管理入口,按钮权限与菜单 SQL 保持一致。 */ +@RestController +@RequestMapping("/member/app-version") +@Validated +@Tag(name = "管理后台 - App 版本") +public class AppVersionController { + /** 可信包与发布业务。 */ + @Resource private AppVersionService service; + + /** 包上传走专用权限入口,平台与 AppID 由服务端验证。 */ + @PostMapping("/upload-package") + @Operation(summary = "上传并校验 APK/WGT 包") + @PreAuthorize("@ss.hasAnyPermissions('app:version:create', 'app:version:update')") + public CommonResult upload(@RequestPart("file") MultipartFile file, @RequestParam(value = "platform", required = false) String platform) { + return success(service.upload(file, platform)); + } + + /** 创建草稿。 */ + @PostMapping("/create") + @Operation(summary = "创建版本草稿") + @PreAuthorize("@ss.hasPermission('app:version:create')") + public CommonResult create(@Valid @RequestBody AppVersionSaveReqVO request) { + return success(service.create(request)); + } + + /** 编辑草稿或下架版本。 */ + @PutMapping("/update") + @Operation(summary = "编辑版本") + @PreAuthorize("@ss.hasPermission('app:version:update')") + public CommonResult update(@Valid @RequestBody AppVersionSaveReqVO request) { + service.update(request); + return success(true); + } + + /** 删除可编辑记录。 */ + @DeleteMapping("/delete") + @Operation(summary = "删除版本") + @PreAuthorize("@ss.hasPermission('app:version:delete')") + public CommonResult delete(@RequestParam("id") @Positive Long id) { + service.delete(id); + return success(true); + } + + /** 读取详情和灰测名单。 */ + @GetMapping("/get") + @Operation(summary = "版本详情") + @PreAuthorize("@ss.hasPermission('app:version:query')") + public CommonResult get(@RequestParam("id") @Positive Long id) { + return success(service.get(id)); + } + + /** 版本分页。 */ + @GetMapping("/page") + @Operation(summary = "版本分页") + @PreAuthorize("@ss.hasPermission('app:version:query')") + public CommonResult> page(@Valid AppVersionPageReqVO request) { + return success(service.page(request)); + } + + /** 选择会员无需额外授予完整会员管理权限。 */ + @GetMapping("/member-page") + @Operation(summary = "灰测会员候选分页") + @PreAuthorize("@ss.hasPermission('app:version:query')") + public CommonResult> memberPage(@Valid AppVersionMemberPageReqVO request) { + return success(service.memberPage(request)); + } + + /** 发布或重新发布。 */ + @PutMapping("/publish") + @Operation(summary = "发布版本") + @PreAuthorize("@ss.hasPermission('app:version:publish')") + public CommonResult publish(@RequestParam("id") @Positive Long id) { + service.publish(id); + return success(true); + } + + /** 下架立即影响下一次客户端验证。 */ + @PutMapping("/offline") + @Operation(summary = "下架版本") + @PreAuthorize("@ss.hasPermission('app:version:publish')") + public CommonResult offline(@RequestParam("id") @Positive Long id) { + service.offline(id); + return success(true); + } + + /** 灰测转全量。 */ + @PutMapping("/promote") + @Operation(summary = "灰测版本转全量") + @PreAuthorize("@ss.hasPermission('app:version:publish')") + public CommonResult promote(@RequestParam("id") @Positive Long id) { + service.promote(id); + return success(true); + } + + /** 复制既有灰测名单。 */ + @PostMapping("/copy-gray-members") + @Operation(summary = "沿用版本灰测名单") + @PreAuthorize("@ss.hasPermission('app:version:update')") + public CommonResult copyMembers(@Valid @RequestBody CopyMembersRequest request) { + service.copyMembers(request.getId(), request.getSourceVersionId()); + return success(true); + } + + /** 名单复制请求,目标必须处于可编辑状态。 */ + @Data + public static class CopyMembersRequest { + /** 目标版本编号。 */ + @NotNull @Positive private Long id; + /** 灰测源版本编号。 */ + @NotNull @Positive private Long sourceVersionId; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionMemberPageReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionMemberPageReqVO.java new file mode 100644 index 0000000..840dacb --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionMemberPageReqVO.java @@ -0,0 +1,15 @@ +package cn.iocoder.yudao.module.member.controller.admin.appversion.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; +import javax.validation.constraints.Size; + +/** 轻量会员候选分页,只列出当前租户的正常会员。 */ +@Data +@EqualsAndHashCode(callSuper = true) +public class AppVersionMemberPageReqVO extends PageParam { + /** 匹配用户 Code、内部主键、昵称或手机号。 */ + @Size(max = 100) + private String keyword; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionMemberRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionMemberRespVO.java new file mode 100644 index 0000000..9754c5a --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionMemberRespVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.member.controller.admin.appversion.vo; + +import lombok.Data; + +/** 灰测选择器所需的最小会员信息。 */ +@Data +public class AppVersionMemberRespVO { + /** 真实会员主键。 */ + private Long id; + + /** App 展示用户 ID。 */ + private String userCode; + + /** 会员昵称。 */ + private String nickname; + + /** 会员头像。 */ + private String avatar; + + /** 会员手机号。 */ + private String mobile; + + /** 会员状态,0 为正常。 */ + private Integer status; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionPackageRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionPackageRespVO.java new file mode 100644 index 0000000..f15866e --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionPackageRespVO.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.member.controller.admin.appversion.vo; + +import lombok.Data; +import java.util.List; + +/** 已通过服务端包结构和摘要校验的安装包信息。 */ +@Data +public class AppVersionPackageRespVO { + /** 可信包记录编号。 */ + private Long id; + + /** uni-app 编译清单中的 AppID。 */ + private String appId; + + /** 后台为规范 CSV,App 检查响应为请求的单个平台,兼容旧字段。 */ + private String platform; + + /** 目标平台列表,WGT 可同时发布到 Android、iOS。 */ + private List platforms; + + /** 安装包类型 apk/wgt。 */ + private String packageType; + + /** 上传时的原始文件名。 */ + private String fileName; + + /** 已校验文件大小,单位字节。 */ + private Long fileSize; + + /** 服务端流式计算的 SHA-256 十六进制摘要。 */ + private String sha256; + + /** 服务端流式计算的 MD5,供当前原生运行时校验。 */ + private String md5; + + /** 由已有文件存储配置生成的下载地址。 */ + private String downloadUrl; + + /** 管理列表版本码,APK 使用原生版本,WGT 使用资源版本。 */ + private Integer versionCode; + + /** 管理列表展示版本名,来源于包清单。 */ + private String versionName; + + /** APK 原生基座版本码,WGT 为空。 */ + private Integer nativeVersionCode; + + /** 包内 uni-app 资源版本码。 */ + private Integer resourceVersionCode; + + /** Android 原生包名,WGT 为空。 */ + private String packageName; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionPageReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionPageReqVO.java new file mode 100644 index 0000000..88e956c --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionPageReqVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.member.controller.admin.appversion.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** 版本管理分页筛选。 */ +@Data +@EqualsAndHashCode(callSuper = true) +public class AppVersionPageReqVO extends PageParam { + /** 版本名称或更新说明关键词。 */ + private String keyword; + + /** 平台筛选。 */ + private String platform; + + /** 包类型筛选。 */ + private String packageType; + + /** 发布状态筛选。 */ + private String status; + + /** 发布范围筛选。 */ + private String releaseScope; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionRespVO.java new file mode 100644 index 0000000..1bde87a --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionRespVO.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.member.controller.admin.appversion.vo; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import java.time.LocalDateTime; +import java.util.List; + +/** 管理页面与更新客户端共用的发布元数据,包信息由服务器生成。 */ +@Data +@EqualsAndHashCode(callSuper = true) +public class AppVersionRespVO extends AppVersionPackageRespVO { + /** 已校验版本包记录编号。 */ + private Long packageId; + + /** 向用户展示的纯文本更新说明。 */ + private String updateContent; + + /** 是否要求用户完成此次更新。 */ + private Boolean forceUpdate; + + /** 发布范围 all 全量、members 指定会员。 */ + private String releaseScope; + + /** 发布状态 draft/published/offline。 */ + private String status; + + /** WGT 最低兼容原生基座版本码,APK 为空。 */ + private Integer minNativeVersionCode; + + /** WGT 最高兼容原生基座版本码,空表示无上限。 */ + private Integer maxNativeVersionCode; + + /** 最近一次正式发布的服务器时间。 */ + private java.time.LocalDateTime publishTime; + + /** 灰测名单人数。 */ + private Integer memberCount; + + /** 管理后台返回的灰测会员主键。 */ + private List memberIds; + + /** 管理后台详情中的灰测会员展示信息。 */ + private List members; + + /** 版本记录创建时间。 */ + private LocalDateTime createTime; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionSaveReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionSaveReqVO.java new file mode 100644 index 0000000..934f6fc --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/appversion/vo/AppVersionSaveReqVO.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.member.controller.admin.appversion.vo; + +import lombok.Data; +import javax.validation.constraints.*; +import java.util.List; + +/** 新建和编辑草稿/下架版本,包版本信息始终从可信包记录读取。 */ +@Data +public class AppVersionSaveReqVO { + /** 更新时必填的版本编号。 */ + private Long id; + /** 已上传且校验成功的包编号。 */ + @NotNull @Positive + private Long packageId; + /** 权威发布目标;旧调用缺省时更新沿用原版本,新建沿用包默认目标。 */ + private List<@NotNull @Pattern(regexp = "android|ios|harmony") String> platforms; + /** 向用户展示的更新说明,保留换行。 */ + @NotBlank @Size(max = 4000) + private String updateContent; + /** 普通更新或强制更新。 */ + @NotNull + private Boolean forceUpdate; + /** all 或 members。 */ + @NotBlank @Pattern(regexp = "all|members") + private String releaseScope; + /** WGT 最低原生版本码,保存时按包类型校验。 */ + @Positive + private Integer minNativeVersionCode; + /** WGT 最高原生版本码,空表示无上限。 */ + @Positive + private Integer maxNativeVersionCode; + /** 指定会员名单,保存时去重并批量校验有效性。 */ + private List<@NotNull @Positive Long> memberIds; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/MemberPushController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/MemberPushController.java new file mode 100644 index 0000000..53b4731 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/MemberPushController.java @@ -0,0 +1,59 @@ +package cn.iocoder.yudao.module.member.controller.admin.push; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.member.controller.admin.push.vo.*; +import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserPageReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.push.*; +import cn.iocoder.yudao.module.member.service.push.MemberPushTaskService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import javax.annotation.Resource; +import javax.validation.Valid; +import java.util.Collections; +import java.util.Map; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - App 推送") +@RestController +@RequestMapping("/member/push") +@Validated +public class MemberPushController { + @Resource + private MemberPushTaskService service; + + @PostMapping("/send") + @Operation(summary = "创建手动推送任务") + @PreAuthorize("@ss.hasPermission('member:push:send')") + public CommonResult> send(@Valid @RequestBody MemberPushSendReqVO req) { + return success(Collections.singletonMap("taskId", service.createManual(req, getLoginUserId()))); + } + + @GetMapping("/page") + @PreAuthorize("@ss.hasPermission('member:push:query')") + public CommonResult> page(@Valid MemberPushPageReqVO req) { + return success(service.page(req)); + } + + @GetMapping("/get") + @PreAuthorize("@ss.hasPermission('member:push:query')") + public CommonResult get(@RequestParam Long id) { + return success(service.get(id)); + } + + @GetMapping("/record-page") + @PreAuthorize("@ss.hasPermission('member:push:query')") + public CommonResult> records(@Valid MemberPushRecordPageReqVO req) { + return success(service.records(req)); + } + + @GetMapping("/user-page") + @PreAuthorize("@ss.hasAnyPermissions('member:push:query','member:push:send')") + public CommonResult> users(@Valid MemberUserPageReqVO req) { + return success(service.users(req)); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/vo/MemberPushPageReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/vo/MemberPushPageReqVO.java new file mode 100644 index 0000000..efc13c0 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/vo/MemberPushPageReqVO.java @@ -0,0 +1,17 @@ +package cn.iocoder.yudao.module.member.controller.admin.push.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = true) +public class MemberPushPageReqVO extends PageParam { + private String source; + private Long memberId; + private String status; + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime[] createTime; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/vo/MemberPushRecordPageReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/vo/MemberPushRecordPageReqVO.java new file mode 100644 index 0000000..06b5415 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/vo/MemberPushRecordPageReqVO.java @@ -0,0 +1,14 @@ +package cn.iocoder.yudao.module.member.controller.admin.push.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Positive; + +@Data +@EqualsAndHashCode(callSuper = true) +public class MemberPushRecordPageReqVO extends PageParam { + @NotNull @Positive + private Long taskId; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/vo/MemberPushSendReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/vo/MemberPushSendReqVO.java new file mode 100644 index 0000000..d889eaf --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/vo/MemberPushSendReqVO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.member.controller.admin.push.vo; + +import lombok.Data; +import javax.validation.constraints.*; +import java.util.List; + +@Data +public class MemberPushSendReqVO { + @NotBlank @Size(max = 64) + private String requestId; + @NotBlank @Pattern(regexp = "USER|ALL") + private String targetType; + private List<@NotNull @Positive Long> memberIds; + @NotBlank @Size(max = 40) + private String title; + @NotBlank @Size(max = 100) + private String content; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/vo/MemberPushUserRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/vo/MemberPushUserRespVO.java new file mode 100644 index 0000000..f685e28 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/push/vo/MemberPushUserRespVO.java @@ -0,0 +1,12 @@ +package cn.iocoder.yudao.module.member.controller.admin.push.vo; + +import lombok.Data; + +@Data +public class MemberPushUserRespVO { + private Long id; + private String nickname; + private String userCode; + private String mobile; + private Long deviceCount; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/thirdcallback/ImPushCallbackController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/thirdcallback/ImPushCallbackController.java new file mode 100644 index 0000000..05dd397 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/thirdcallback/ImPushCallbackController.java @@ -0,0 +1,78 @@ +package cn.iocoder.yudao.module.member.controller.admin.thirdcallback; + +import cn.hutool.json.JSONUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.member.service.push.ImPrivatePushEvent; +import cn.iocoder.yudao.module.member.service.push.MemberPushTaskService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import javax.annotation.Resource; +import javax.annotation.security.PermitAll; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import javax.validation.Validator; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.Collections; +import java.util.Map; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@RestController +@RequestMapping("/third-callback") +public class ImPushCallbackController { + @Value("${IM_PUSH_CALLBACK_SECRET:}") + private String secret; + @Resource + private MemberPushTaskService service; + @Resource + private Validator validator; + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> callbackError(ResponseStatusException error) { + return ResponseEntity.status(error.getStatus()) + .body(CommonResult.error(error.getRawStatusCode(), error.getReason())); + } + + @PermitAll + @PostMapping("/im-private-message") + public CommonResult> receive(@RequestBody String body, + @RequestHeader("X-IM-TIMESTAMP") String timestamp, + @RequestHeader("X-IM-SIGNATURE") String signature) { + verify(body, timestamp, signature); + ImPrivatePushEvent event; + try { + event = JSONUtil.toBean(body, ImPrivatePushEvent.class); + if (event == null || !validator.validate(event).isEmpty() + || !("private:" + event.getMessageId()).equals(event.getEventId())) { + throw new IllegalArgumentException(); + } + } catch (Exception e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "推送事件参数无效"); + } + return success(Collections.singletonMap("taskId", service.acceptPrivate(event))); + } + + void verify(String body, String timestamp, String signature) { + try { + long seconds = Long.parseLong(timestamp); + long now = Instant.now().getEpochSecond(); + if (secret == null || secret.trim().isEmpty() || seconds < now - 300 || seconds > now + 300 + || body.length() > 65536 || !signature.matches("[0-9a-fA-F]{64}")) { + throw new IllegalArgumentException(); + } + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] expected = mac.doFinal((timestamp + "\n" + body).getBytes(StandardCharsets.UTF_8)); + byte[] supplied = org.apache.commons.codec.binary.Hex.decodeHex(signature.toCharArray()); + if (!MessageDigest.isEqual(expected, supplied)) { + throw new IllegalArgumentException(); + } + } catch (Exception e) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "IM 推送回调签名无效"); + } + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/appversion/AppAppVersionController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/appversion/AppAppVersionController.java new file mode 100644 index 0000000..2b09389 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/appversion/AppAppVersionController.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.member.controller.app.appversion; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.member.controller.app.appversion.vo.AppVersionUpdateRespVO; +import javax.servlet.http.HttpServletResponse; +import cn.iocoder.yudao.module.member.controller.app.appversion.vo.AppVersionCheckReqVO; +import cn.iocoder.yudao.module.member.service.appversion.AppVersionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import javax.annotation.Resource; +import javax.annotation.security.PermitAll; +import javax.validation.Valid; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** 更新检查允许未登录访问全量版本,灰测身份始终由安全上下文解析。 */ +@RestController +@RequestMapping("/member/app-version") +@Validated +@Tag(name = "用户 App - 版本更新") +public class AppAppVersionController { + /** 发布候选查询服务。 */ + @Resource private AppVersionService service; + + /** 查询当前安装状态可用的最高资源版本,data 为空表示当前无需更新。 */ + @GetMapping("/check") + @PermitAll + @Operation(summary = "检查可用更新") + public CommonResult check(@Valid AppVersionCheckReqVO request, HttpServletResponse response) { + response.setHeader("Cache-Control", "private, no-store"); + return success(BeanUtils.toBean(service.check(request), AppVersionUpdateRespVO.class)); + } + + /** 安装前重新确认目标发布仍为当前可用候选,失效时返回空数据。 */ + @GetMapping("/validate") + @PermitAll + @Operation(summary = "重新验证更新资格") + public CommonResult validate(@Valid AppVersionCheckReqVO request, HttpServletResponse response) { + response.setHeader("Cache-Control", "private, no-store"); + return success(BeanUtils.toBean(service.validate(request), AppVersionUpdateRespVO.class)); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/appversion/vo/AppVersionCheckReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/appversion/vo/AppVersionCheckReqVO.java new file mode 100644 index 0000000..f47824e --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/appversion/vo/AppVersionCheckReqVO.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.member.controller.app.appversion.vo; + +import lombok.Data; +import javax.validation.constraints.*; + +/** 原生客户端报告安装中的基座和资源版本,会员身份仅从登录上下文获取。 */ +@Data +public class AppVersionCheckReqVO { + /** uni-app AppID。 */ + @NotBlank @Size(max = 100) + private String appId; + /** 实际运行平台。 */ + @NotBlank @Pattern(regexp = "android|ios|harmony") + private String platform; + /** 设备当前安装的原生基座版本。 */ + @NotNull @Positive + private Integer nativeVersionCode; + /** 设备当前安装的 uni-app 资源版本。 */ + @NotNull @Positive + private Integer resourceVersionCode; + /** Android 原生包名,用于避免提供其他包名的 APK。 */ + @Size(max = 200) + private String packageName; + /** validate 接口需要的目标发布编号。 */ + @Positive + private Long id; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/appversion/vo/AppVersionUpdateRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/appversion/vo/AppVersionUpdateRespVO.java new file mode 100644 index 0000000..541c43a --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/appversion/vo/AppVersionUpdateRespVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.member.controller.app.appversion.vo; + +import cn.iocoder.yudao.module.member.controller.admin.appversion.vo.AppVersionPackageRespVO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** 客户端所需更新信息,不包含后台灰测会员名单。 */ +@Data +@EqualsAndHashCode(callSuper = true) +public class AppVersionUpdateRespVO extends AppVersionPackageRespVO { + /** 发布关联的可信包编号,继承的 id 为发布编号。 */ + private Long packageId; + /** 是否要求完成更新。 */ + private Boolean forceUpdate; + /** 纯文本更新说明。 */ + private String updateContent; + /** 最低兼容原生基座版本。 */ + private Integer minNativeVersionCode; + /** 最高兼容原生基座版本,空表示无上限。 */ + private Integer maxNativeVersionCode; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/chat/AppChatController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/chat/AppChatController.java index 38f8952..199166b 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/chat/AppChatController.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/chat/AppChatController.java @@ -6,7 +6,6 @@ import cn.iocoder.yudao.module.member.client.vo.chat.friend.ChatAddFriendReqVO; import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatPrivateMessageSendReqVO; import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatPrivateMessageSendResVO; import cn.iocoder.yudao.module.member.service.opentype.OpenTypeService; -import cn.iocoder.yudao.module.member.service.push.MemberPushService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.validation.annotation.Validated; @@ -28,8 +27,6 @@ public class AppChatController { @Resource private ChatApi chatApi; - @Resource - private MemberPushService memberPushService; @PostMapping("/send-private-msg") @Operation(summary = "发送私聊消息(有会员限制)") @@ -39,9 +36,6 @@ public class AppChatController { body.setSendId(sendId); body.setTerminal(1); ChatPrivateMessageSendResVO result = chatApi.send(body); - if (result != null) { - memberPushService.pushPrivateMessage(body); - } return success(result); } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/push/vo/AppPushBindReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/push/vo/AppPushBindReqVO.java index 4046c3f..f242545 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/push/vo/AppPushBindReqVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/push/vo/AppPushBindReqVO.java @@ -4,6 +4,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; @Schema(description = "用户 App - 推送设备绑定 Request VO") @Data @@ -11,16 +13,23 @@ public class AppPushBindReqVO { @Schema(description = "极光 RegistrationID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1a2b3c") @NotBlank(message = "registrationId 不能为空") + @Size(max = 64) private String registrationId; + @Schema(description = "设备注册所属极光 AppKey,旧版本未上报时保留为空", example = "447fcdbce3b2dcac3091c0de") + @Size(max = 64, message = "appKey 最多 64 个字符") + private String appKey; + @Schema(description = "极光别名", example = "u1024") private String alias; @Schema(description = "平台:ios / android", requiredMode = Schema.RequiredMode.REQUIRED, example = "android") @NotBlank(message = "platform 不能为空") + @Pattern(regexp = "(?i)ios|android", message = "platform 仅支持 ios 或 android") private String platform; @Schema(description = "推送服务商", example = "jpush") + @Pattern(regexp = "(?i)jpush|", message = "provider 仅支持 jpush") private String provider; } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/appversion/AppVersionDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/appversion/AppVersionDO.java new file mode 100644 index 0000000..6e7b4ba --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/appversion/AppVersionDO.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.appversion; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** App 版本发布及兼容性策略。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("member_app_version") +public class AppVersionDO extends TenantBaseDO { + /** 数据库自增编号。 */ + @TableId + private Long id; + + /** 已校验版本包记录编号。 */ + private Long packageId; + + /** uni-app 编译清单中的 AppID。 */ + private String appId; + + /** 规范 CSV 目标平台,单平台历史值直接兼容。 */ + private String platform; + + /** 安装包类型 apk/wgt。 */ + private String packageType; + + /** 管理列表版本码,APK 使用原生版本,WGT 使用资源版本。 */ + private Integer versionCode; + + /** 管理列表展示版本名,来源于包清单。 */ + private String versionName; + + /** APK 原生基座版本码,WGT 为空。 */ + private Integer nativeVersionCode; + + /** 包内 uni-app 资源版本码。 */ + private Integer resourceVersionCode; + + /** Android 原生包名,WGT 为空。 */ + private String packageName; + + /** 向用户展示的纯文本更新说明。 */ + private String updateContent; + + /** 是否要求用户完成此次更新。 */ + private Boolean forceUpdate; + + /** 发布范围 all 全量、members 指定会员。 */ + private String releaseScope; + + /** 发布状态 draft/published/offline。 */ + private String status; + + /** WGT 最低兼容原生基座版本码,APK 为空。 */ + private Integer minNativeVersionCode; + + /** WGT 最高兼容原生基座版本码,空表示无上限。 */ + private Integer maxNativeVersionCode; + + /** 最近一次正式发布的服务器时间。 */ + private java.time.LocalDateTime publishTime; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/appversion/AppVersionMemberDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/appversion/AppVersionMemberDO.java new file mode 100644 index 0000000..4adb1b7 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/appversion/AppVersionMemberDO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.appversion; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** 每个版本独立的灰测会员名单。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("member_app_version_member") +public class AppVersionMemberDO extends TenantBaseDO { + /** 数据库自增编号。 */ + @TableId + private Long id; + + /** 版本发布编号。 */ + private Long versionId; + + /** 会员管理中的真实会员主键。 */ + private Long memberId; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/appversion/AppVersionPackageDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/appversion/AppVersionPackageDO.java new file mode 100644 index 0000000..0c8592a --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/appversion/AppVersionPackageDO.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.appversion; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** 可信安装包及其服务端校验结果。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("member_app_version_package") +public class AppVersionPackageDO extends TenantBaseDO { + /** 数据库自增编号。 */ + @TableId + private Long id; + + /** uni-app 编译清单中的 AppID。 */ + private String appId; + + /** 规范 CSV 目标平台,单平台历史值直接兼容。 */ + private String platform; + + /** 安装包类型 apk/wgt。 */ + private String packageType; + + /** 上传时的原始文件名。 */ + private String fileName; + + /** 已校验文件大小,单位字节。 */ + private Long fileSize; + + /** 服务端流式计算的 SHA-256 十六进制摘要。 */ + private String sha256; + + /** 服务端流式计算的 MD5,供当前原生运行时校验。 */ + private String md5; + + /** 由已有文件存储配置生成的下载地址。 */ + private String downloadUrl; + + /** 管理列表版本码,APK 使用原生版本,WGT 使用资源版本。 */ + private Integer versionCode; + + /** 管理列表展示版本名,来源于包清单。 */ + private String versionName; + + /** APK 原生基座版本码,WGT 为空。 */ + private Integer nativeVersionCode; + + /** 包内 uni-app 资源版本码。 */ + private Integer resourceVersionCode; + + /** Android 原生包名,WGT 为空。 */ + private String packageName; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/push/MemberPushDeviceDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/push/MemberPushDeviceDO.java index 913e578..2df0131 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/push/MemberPushDeviceDO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/push/MemberPushDeviceDO.java @@ -47,4 +47,9 @@ public class MemberPushDeviceDO extends TenantBaseDO { */ private String provider; + /** + * 设备注册所属极光应用,历史未知应用为空字符串 + */ + private String appKey; + } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/push/MemberPushRecordDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/push/MemberPushRecordDO.java new file mode 100644 index 0000000..2819af7 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/push/MemberPushRecordDO.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.push; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("member_push_record") +public class MemberPushRecordDO extends TenantBaseDO { + private Long id; + private Long taskId; + private Long memberId; + private String nickname; + private String userCode; + private Long deviceId; + private String platform; + private String registrationId; + private String status; + private Integer attemptCount; + private String cid; + @com.fasterxml.jackson.annotation.JsonIgnore + private String batchPayload; + private String jpushMsgId; + private String errorCode; + private String errorMessage; + @com.fasterxml.jackson.annotation.JsonIgnore + private String foregroundSession; + @com.fasterxml.jackson.annotation.JsonIgnore + private Long foregroundVersion; + private LocalDateTime nextRetryTime; + private LocalDateTime sendTime; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/push/MemberPushTaskDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/push/MemberPushTaskDO.java new file mode 100644 index 0000000..27ed4c3 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/push/MemberPushTaskDO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.push; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("member_push_task") +public class MemberPushTaskDO extends TenantBaseDO { + private Long id; + @com.fasterxml.jackson.annotation.JsonIgnore + private String requestKey; + @com.fasterxml.jackson.annotation.JsonIgnore + private String requestHash; + private String source; + private String targetType; + @com.fasterxml.jackson.annotation.JsonIgnore + private String memberIds; + private Long operatorId; + private Long messageId; + private Long senderId; + private Long receiverId; + private String title; + private String content; + private String status; + private Boolean audienceComplete; + private Long audienceCursor; + private Long snapshotMaxId; + private Integer totalMembers; + private Integer totalDevices; + private Integer submittedCount; + private Integer failedCount; + private Integer skippedCount; + private Integer processedCount; + private String lastError; + private Integer attemptCount; + private LocalDateTime nextRetryTime; + private LocalDateTime expiresAt; + private LocalDateTime finishTime; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/appversion/AppVersionMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/appversion/AppVersionMapper.java new file mode 100644 index 0000000..cba9f0d --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/appversion/AppVersionMapper.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.member.dal.mysql.appversion; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.member.controller.admin.appversion.vo.AppVersionPageReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.appversion.AppVersionDO; +import cn.hutool.core.util.StrUtil; +import org.apache.ibatis.annotations.*; + +/** 版本发布持久化,查询和行锁均经过既有多租户拦截器。 */ +@Mapper +public interface AppVersionMapper extends BaseMapperX { + /** 锁定仍有效的发布记录,串行化编辑、名单替换和发布状态变更。 */ + @Select("SELECT * FROM member_app_version WHERE id = #{id} AND deleted = 0 FOR UPDATE") + AppVersionDO selectForUpdate(@Param("id") Long id); + + /** 按发布编号倒序分页,筛选值全部使用参数绑定。 */ + default PageResult selectPage(AppVersionPageReqVO request) { + LambdaQueryWrapperX query = new LambdaQueryWrapperX() + .eqIfPresent(AppVersionDO::getPackageType, request.getPackageType()) + .eqIfPresent(AppVersionDO::getStatus, request.getStatus()) + .eqIfPresent(AppVersionDO::getReleaseScope, request.getReleaseScope()); + query.apply(StrUtil.isNotBlank(request.getPlatform()), "FIND_IN_SET({0}, platform) > 0", request.getPlatform()); + if (StrUtil.isNotBlank(request.getKeyword())) { + query.and(q -> { + q.like(AppVersionDO::getVersionName, request.getKeyword()) + .or().like(AppVersionDO::getUpdateContent, request.getKeyword()); + if (request.getKeyword().matches("[0-9]{1,10}")) { + long code = Long.parseLong(request.getKeyword()); + if (code <= Integer.MAX_VALUE) { + q.or().eq(AppVersionDO::getVersionCode, code).or().eq(AppVersionDO::getResourceVersionCode, code); + } + } + }); + } + return selectPage(request, query.orderByDesc(AppVersionDO::getId)); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/appversion/AppVersionMemberMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/appversion/AppVersionMemberMapper.java new file mode 100644 index 0000000..82de44b --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/appversion/AppVersionMemberMapper.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.member.dal.mysql.appversion; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.member.dal.dataobject.appversion.AppVersionMemberDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Param; +import lombok.Data; +import java.util.Collection; +import java.util.List; + +/** 每版本灰测名单,写入在版本行锁事务内完成。 */ +@Mapper +public interface AppVersionMemberMapper extends BaseMapperX { + /** 在数据库内聚合当前页版本人数,避免大名单逐行加载到 Java 堆。 */ + @Select({""}) + List selectCounts(@Param("ids") Collection ids); + + /** 当前页版本的灰测人数聚合结果。 */ + @Data + class MemberCount { + /** 版本发布编号。 */ + private Long versionId; + /** 有效名单人数。 */ + private Long memberCount; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/appversion/AppVersionPackageMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/appversion/AppVersionPackageMapper.java new file mode 100644 index 0000000..20367fb --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/appversion/AppVersionPackageMapper.java @@ -0,0 +1,10 @@ +package cn.iocoder.yudao.module.member.dal.mysql.appversion; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.member.dal.dataobject.appversion.AppVersionPackageDO; +import org.apache.ibatis.annotations.Mapper; + +/** 可信安装包查询,自动应用租户和逻辑删除隔离。 */ +@Mapper +public interface AppVersionPackageMapper extends BaseMapperX { +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/push/MemberPushDeviceMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/push/MemberPushDeviceMapper.java index d05b76c..b9f028d 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/push/MemberPushDeviceMapper.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/push/MemberPushDeviceMapper.java @@ -6,6 +6,8 @@ import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushDeviceDO; import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Insert; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; import java.util.List; @@ -16,16 +18,25 @@ public interface MemberPushDeviceMapper extends BaseMapperX return selectOne(MemberPushDeviceDO::getRegistrationId, registrationId); } - default List selectListByMemberId(Long memberId) { + default List selectListByMemberId(Long memberId, String appKey) { return selectList(new LambdaQueryWrapperX() .eq(MemberPushDeviceDO::getMemberId, memberId) + .eq(MemberPushDeviceDO::getAppKey, appKey) + .ne(MemberPushDeviceDO::getAppKey, "") + .eq(MemberPushDeviceDO::getProvider, "jpush") .orderByDesc(MemberPushDeviceDO::getId)); } - /** - * 物理删除,避免逻辑删除后 registration_id 唯一索引冲突。 - */ - @Delete("DELETE FROM member_push_device WHERE id = #{id}") - int physicalDeleteById(@Param("id") Long id); + /** RegistrationID 标识应用安装实例,登录切换时原子迁移到当前认证会员。 */ + @InterceptorIgnore(tenantLine = "true") + @Insert("INSERT INTO member_push_device(member_id,registration_id,alias,platform,provider,app_key,tenant_id) " + + "VALUES(#{memberId},#{registrationId},#{alias},#{platform},'jpush',#{appKey},#{tenantId}) " + + "ON DUPLICATE KEY UPDATE member_id=VALUES(member_id),alias=VALUES(alias)," + + "platform=VALUES(platform),provider='jpush',app_key=VALUES(app_key),tenant_id=VALUES(tenant_id)," + + "deleted=0,update_time=CURRENT_TIMESTAMP") + int bindDevice(MemberPushDeviceDO device); + + @Delete("DELETE FROM member_push_device WHERE member_id = #{memberId} AND registration_id = #{registrationId}") + int unbindDevice(@Param("memberId") Long memberId, @Param("registrationId") String registrationId); } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/push/MemberPushRecordMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/push/MemberPushRecordMapper.java new file mode 100644 index 0000000..afba8f1 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/push/MemberPushRecordMapper.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.member.dal.mysql.push; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushRecordDO; +import org.apache.ibatis.annotations.*; +import java.util.Map; + +@Mapper +public interface MemberPushRecordMapper extends BaseMapperX { + @Select("SELECT COUNT(DISTINCT member_id) AS total_members, " + + "COALESCE(SUM(device_id IS NOT NULL),0) AS total_devices, " + + "COALESCE(SUM(device_id IS NOT NULL AND status = 'SUBMITTED'),0) AS submitted_count, " + + "COALESCE(SUM(device_id IS NOT NULL AND status = 'FAILED'),0) AS failed_count, " + + "COALESCE(SUM(device_id IS NOT NULL AND status = 'SKIPPED'),0) AS skipped_count, " + + "COALESCE(SUM(status IN ('WAITING','SENDING')),0) AS pending_count " + + "FROM member_push_record WHERE task_id = #{taskId} AND deleted = 0") + Map summarize(@Param("taskId") Long taskId); +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/push/MemberPushTaskMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/push/MemberPushTaskMapper.java new file mode 100644 index 0000000..3b60de5 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/push/MemberPushTaskMapper.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.member.dal.mysql.push; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.member.controller.admin.push.vo.MemberPushPageReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushTaskDO; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import org.apache.ibatis.annotations.*; +import java.util.List; +import java.time.LocalDateTime; + +@Mapper +public interface MemberPushTaskMapper extends BaseMapperX { + @InterceptorIgnore(tenantLine = "true") + @Select("SELECT * FROM member_push_task WHERE deleted = 0 AND status IN ('WAITING','RUNNING') " + + "AND next_retry_time <= #{now} ORDER BY next_retry_time, id LIMIT 20") + List selectPendingAcrossTenants(@Param("now") LocalDateTime now); + + default PageResult selectPage(MemberPushPageReqVO req) { + LambdaQueryWrapperX query = new LambdaQueryWrapperX() + .eqIfPresent(MemberPushTaskDO::getSource, req.getSource()) + .eqIfPresent(MemberPushTaskDO::getStatus, req.getStatus()) + .betweenIfPresent(MemberPushTaskDO::getCreateTime, req.getCreateTime()) + .orderByDesc(MemberPushTaskDO::getId); + if (req.getMemberId() != null) { + query.apply("EXISTS (SELECT 1 FROM member_push_record r WHERE r.task_id = member_push_task.id " + + "AND r.deleted = 0 AND r.member_id = {0})", req.getMemberId()); + } + return selectPage(req, query); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/appversion/AndroidManifestReader.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/appversion/AndroidManifestReader.java new file mode 100644 index 0000000..23128e7 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/appversion/AndroidManifestReader.java @@ -0,0 +1,445 @@ +package cn.iocoder.yudao.module.member.service.appversion; + +import cn.iocoder.yudao.module.member.dal.dataobject.appversion.AppVersionPackageDO; + +import java.io.*; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * 按 Android ResourceTypes 格式定向读取包名与版本,不为包内计数字段分配数组。 + * 元数据条目先有界落盘,再按偏移读取;版本名为资源引用时仅定位该资源及其字符串。 + * 格式依据 AOSP libs/androidfw/include/androidfw/ResourceTypes.h。 + */ +final class AndroidManifestReader { + /** Android 属性的固定命名空间。 */ + private static final String ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android"; + /** 二进制值类型中的字符串。 */ + private static final int TYPE_STRING = 3; + /** 二进制值类型中的资源引用。 */ + private static final int TYPE_REFERENCE = 1; + /** 缺失字符串或资源偏移的无符号标记。 */ + private static final long NO_INDEX = 0xffff_ffffL; + + /** 读取并填充原生元数据,资源版本仍由调用方读取的 uni-app 清单提供。 */ + static void read(ZipFile zip, AppVersionPackageDO result) throws IOException { + try (EntryData data = EntryData.open(zip, "AndroidManifest.xml", 4L * 1024 * 1024)) { + Chunk root = data.chunk(0, data.length); + require(root.type == 3 && root.end == data.length); + StringPool strings = null; + for (long position = root.start + root.headerSize; position < root.end;) { + Chunk chunk = data.chunk(position, root.end); + if (chunk.type == 1) { + require(strings == null); + strings = new StringPool(data, chunk); + } else if (chunk.type == 0x0102) { + require(strings != null); + readManifestElement(zip, data, chunk, strings, result); + return; + } + position = chunk.end; + } + } + throw invalid(); + } + + /** 只读取 manifest 根元素属性,未知属性不会触发资源表遍历。 */ + private static void readManifestElement(ZipFile zip, EntryData data, Chunk chunk, + StringPool strings, AppVersionPackageDO result) throws IOException { + require(chunk.headerSize >= 16); + long extension = chunk.start + chunk.headerSize; + data.range(extension, 20, chunk.end); + require("manifest".equals(strings.string(data.u32(extension + 4), 100))); + int attributeStart = data.u16(extension + 8); + int attributeSize = data.u16(extension + 10); + int attributeCount = data.u16(extension + 12); + require(attributeStart >= 20 && attributeSize >= 20); + long attributes = extension + attributeStart; + data.range(attributes, (long) attributeCount * attributeSize, chunk.end); + String packageName = null; + String versionName = null; + long versionCode = 0; + boolean versionCodeSeen = false; + Long versionNameResource = null; + for (int index = 0; index < attributeCount; index++) { + long attribute = attributes + (long) index * attributeSize; + long namespace = data.u32(attribute); + String name = strings.string(data.u32(attribute + 4), 100); + Value value = data.value(attribute + 12, attribute + attributeSize); + if (namespace == NO_INDEX && "package".equals(name)) { + require(packageName == null && value.type == TYPE_STRING); + packageName = strings.string(value.data, 200); + } else if (namespace != NO_INDEX && ANDROID_NAMESPACE.equals(strings.string(namespace, 100))) { + if ("versionCode".equals(name)) { + require(!versionCodeSeen); + versionCodeSeen = true; + versionCode = integer(value, strings); + } else if ("versionCodeMajor".equals(name)) { + // 客户端协议使用正 int,拒绝无法准确表达的 Android 长版本码。 + require(integer(value, strings) == 0); + } else if ("versionName".equals(name)) { + require(versionName == null && versionNameResource == null); + if (value.type == TYPE_STRING) { + versionName = strings.string(value.data, 100); + } else { + require(value.type == TYPE_REFERENCE); + versionNameResource = value.data; + } + } + } + } + require(packageName != null && !packageName.trim().isEmpty() && versionCode > 0 && versionCode <= Integer.MAX_VALUE); + if (versionNameResource != null) { + try (EntryData resources = EntryData.open(zip, "resources.arsc", 64L * 1024 * 1024)) { + versionName = resourceString(resources, versionNameResource, new HashSet<>()); + } + } + require(versionName != null && !versionName.trim().isEmpty()); + result.setPackageName(packageName).setNativeVersionCode((int) versionCode) + .setVersionCode((int) versionCode).setVersionName(versionName); + } + + /** 整数属性允许 Android 编译器的十进制、十六进制或字符串表示。 */ + private static long integer(Value value, StringPool strings) throws IOException { + if (value.type == 0x10 || value.type == 0x11) { + return value.data; + } + require(value.type == TYPE_STRING); + try { + return Long.parseLong(strings.string(value.data, 20)); + } catch (NumberFormatException ex) { + throw invalid(); + } + } + + /** 按资源 ID 定位单个字符串,最多跟随八级引用,避免循环引用和递归耗尽栈。 */ + private static String resourceString(EntryData data, long resourceId, Set visited) throws IOException { + require(visited.size() < 8 && visited.add(resourceId)); + Chunk root = data.chunk(0, data.length); + require(root.type == 2 && root.headerSize >= 12 && root.end == data.length); + long packageCount = data.u32(root.start + 8); + require(packageCount <= (root.end - root.start - root.headerSize) / 8); + int packageId = (int) ((resourceId >>> 24) & 255); + StringPool strings = null; + Chunk selectedPackage = null; + long actualPackages = 0; + for (long position = root.start + root.headerSize; position < root.end;) { + Chunk chunk = data.chunk(position, root.end); + if (chunk.type == 1) { + require(strings == null); + strings = new StringPool(data, chunk); + } else if (chunk.type == 0x0200) { + require(chunk.headerSize >= 284); + actualPackages++; + if (data.u32(chunk.start + 8) == packageId) { + require(selectedPackage == null); + selectedPackage = chunk; + } + } + position = chunk.end; + } + require(actualPackages == packageCount && strings != null && selectedPackage != null); + Value value = findResource(data, selectedPackage, resourceId); + require(value != null); + if (value.type == TYPE_REFERENCE) { + return resourceString(data, value.data, visited); + } + require(value.type == TYPE_STRING); + return strings.string(value.data, 100); + } + + /** 遍历目标 package 的类型块,优先使用默认配置中的版本名。 */ + private static Value findResource(EntryData data, Chunk pack, long resourceId) throws IOException { + long typeOffset = pack.headerSize >= 288 ? data.u32(pack.start + 284) : 0; + int wantedType = (int) ((resourceId >>> 16) & 255); + int wantedEntry = (int) (resourceId & 0xffff); + Value fallback = null; + for (long position = pack.start + pack.headerSize; position < pack.end;) { + Chunk chunk = data.chunk(position, pack.end); + if (chunk.type == 0x0202) { + require(chunk.headerSize >= 16); + long count = data.u32(chunk.start + 12); + require(count <= 65536); + data.range(chunk.start + chunk.headerSize, count * 4, chunk.end); + } else if (chunk.type == 0x0201) { + require(chunk.headerSize >= 24); + long count = data.u32(chunk.start + 12); + long entriesStart = data.u32(chunk.start + 16); + int flags = data.u8(chunk.start + 9); + require(count <= 65536 && entriesStart >= chunk.headerSize && entriesStart <= chunk.end - chunk.start); + require((flags & ~3) == 0 && flags != 3); + long table = chunk.start + chunk.headerSize; + data.range(table, count * ((flags & 2) != 0 ? 2 : 4), chunk.start + entriesStart); + if (data.u8(chunk.start + 8) + typeOffset == wantedType) { + long configSize = data.u32(chunk.start + 20); + require(configSize >= 4 && configSize <= chunk.headerSize - 20); + long offset = entryOffset(data, table, count, flags, wantedEntry); + if (offset != NO_INDEX) { + Value value = entryValue(data, chunk.start + entriesStart + offset, chunk.end); + if (data.allZero(chunk.start + 24, configSize - 4)) { + return value; + } + if (fallback == null) { + fallback = value; + } + } + } + } + position = chunk.end; + } + return fallback; + } + + /** 支持常规、稀疏和 16 位偏移表,所有偏移都来自已校验的实际索引区域。 */ + private static long entryOffset(EntryData data, long table, long count, int flags, int index) throws IOException { + if ((flags & 1) != 0) { + for (long position = 0; position < count; position++) { + if (data.u16(table + position * 4) == index) { + return (long) data.u16(table + position * 4 + 2) * 4; + } + } + return NO_INDEX; + } + if (index >= count) { + return NO_INDEX; + } + if ((flags & 2) != 0) { + int offset = data.u16(table + (long) index * 2); + return offset == 0xffff ? NO_INDEX : (long) offset * 4; + } + return data.u32(table + (long) index * 4); + } + + /** 支持普通及 AAPT2 compact 简单资源,版本名不能是复杂 bag 资源。 */ + private static Value entryValue(EntryData data, long position, long end) throws IOException { + data.range(position, 8, end); + int flags = data.u16(position + 2); + require((flags & 1) == 0); + if ((flags & 8) != 0) { + return new Value(flags >>> 8, data.u32(position + 4)); + } + int entrySize = data.u16(position); + require(entrySize >= 8); + return data.value(position + entrySize, end); + } + + /** 结构验证失败时返回可处理的包校验错误。 */ + private static void require(boolean valid) { + if (!valid) { + throw invalid(); + } + } + + /** 错误信息只描述文件格式,不回显用户提供的二进制内容。 */ + private static IllegalArgumentException invalid() { + return new IllegalArgumentException("APK 二进制清单或版本名资源结构无效"); + } + + /** Android Res_value 的两个有效数据字段。 */ + private static final class Value { + /** Android 值类型。 */ + private final int type; + /** 无符号 32 位数据或字符串索引。 */ + private final long data; + /** 从已经验证边界的字段构造一个值。 */ + private Value(int type, long data) { this.type = type; this.data = data; } + } + + /** 已验证大小的 Android chunk,所有位置均为文件绝对偏移。 */ + private static final class Chunk { + /** Chunk 起始位置。 */ + private final long start; + /** Chunk 结束位置,不包含该位置。 */ + private final long end; + /** Android chunk 类型。 */ + private final int type; + /** Chunk 头部字节数。 */ + private final int headerSize; + /** 保存已经验证的头部,不按任何计数分配容器。 */ + private Chunk(long start, long end, int type, int headerSize) { + this.start = start; this.end = end; this.type = type; this.headerSize = headerSize; + } + } + + /** 字符串池仅保存偏移布局,按索引读取单条字符串。 */ + private static final class StringPool { + /** 磁盘数据来源。 */ + private final EntryData data; + /** 偏移表起点。 */ + private final long offsets; + /** 实际字符串区域起点。 */ + private final long start; + /** 实际字符串区域终点。 */ + private final long end; + /** 字符串条数,已与真实偏移表容量交叉校验。 */ + private final long count; + /** 字符串编码标记。 */ + private final boolean utf8; + + /** 校验计数、样式偏移和数据区域,乘法在 long 范围内进行。 */ + private StringPool(EntryData data, Chunk chunk) throws IOException { + require(chunk.type == 1 && chunk.headerSize >= 28); + this.data = data; + count = data.u32(chunk.start + 8); + long styles = data.u32(chunk.start + 12); + utf8 = (data.u32(chunk.start + 16) & 0x100) != 0; + long stringsStart = data.u32(chunk.start + 20); + long stylesStart = data.u32(chunk.start + 24); + offsets = chunk.start + chunk.headerSize; + start = chunk.start + stringsStart; + end = stylesStart == 0 ? chunk.end : chunk.start + stylesStart; + require(start >= offsets && start <= end && end <= chunk.end); + data.range(offsets, (count + styles) * 4, start); + } + + /** 严格校验 UTF-8/UTF-16 长度与终止符,只分配不超过业务字段容量的字符串。 */ + private String string(long index, int maximumCharacters) throws IOException { + require(index >= 0 && index < count); + long position = start + data.u32(offsets + index * 4); + if (utf8) { + int first = data.u8(position++); + int characters = (first & 0x80) == 0 ? first : ((first & 0x7f) << 8) | data.u8(position++); + int second = data.u8(position++); + int bytes = (second & 0x80) == 0 ? second : ((second & 0x7f) << 8) | data.u8(position++); + require(characters <= maximumCharacters && bytes <= (long) maximumCharacters * 4); + data.range(position, (long) bytes + 1, end); + require(data.u8(position + bytes) == 0); + String value = decode(data.bytes(position, bytes), true); + require(value.length() == characters); + return value; + } + data.range(position, 2, end); + long characters = data.u16(position); + position += 2; + if ((characters & 0x8000) != 0) { + data.range(position, 2, end); + characters = ((characters & 0x7fff) << 16) | data.u16(position); + position += 2; + } + require(characters <= maximumCharacters); + long bytes = characters * 2; + data.range(position, bytes + 2, end); + require(data.u16(position + bytes) == 0); + return decode(data.bytes(position, (int) bytes), false); + } + + /** 拒绝损坏编码,避免替换字符掩盖包版本的真实字节含义。 */ + private String decode(byte[] bytes, boolean utf8) throws CharacterCodingException { + return (utf8 ? StandardCharsets.UTF_8 : StandardCharsets.UTF_16LE).newDecoder() + .onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)).toString(); + } + } + + /** 有界解压的临时元数据文件,随机访问不会按包内计数增长堆内存。 */ + private static final class EntryData implements AutoCloseable { + /** 元数据临时路径,关闭时删除。 */ + private final Path path; + /** 只读随机访问句柄。 */ + private final RandomAccessFile file; + /** 实际解压字节数。 */ + private final long length; + + /** 打开已经完整落盘的元数据条目。 */ + private EntryData(Path path) throws IOException { + this.path = path; + file = new RandomAccessFile(path.toFile(), "r"); + length = file.length(); + } + + /** 实际复制过程也检查长度,ZIP 声明大小无法绕过容量限制。 */ + private static EntryData open(ZipFile zip, String name, long maximumBytes) throws IOException { + ZipEntry entry = zip.getEntry(name); + require(entry != null && !entry.isDirectory() && entry.getSize() <= maximumBytes); + Path temporary = Files.createTempFile("app-native-metadata-", ".bin"); + try { + long copied = 0; + try (InputStream input = zip.getInputStream(entry); OutputStream output = Files.newOutputStream(temporary)) { + byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = input.read(buffer)) != -1) { + copied += read; + require(copied <= maximumBytes); + output.write(buffer, 0, read); + } + } + require(entry.getSize() < 0 || copied == entry.getSize()); + return new EntryData(temporary); + } catch (IOException | RuntimeException ex) { + Files.deleteIfExists(temporary); + throw ex; + } + } + + /** 验证区间,减法形式避免偏移加法溢出。 */ + private void range(long position, long size, long end) { + require(position >= 0 && size >= 0 && end <= length && position <= end && size <= end - position); + } + + /** 读取单字节无符号值。 */ + private int u8(long position) throws IOException { range(position, 1, length); file.seek(position); return file.readUnsignedByte(); } + /** 读取 little-endian 无符号 16 位值。 */ + private int u16(long position) throws IOException { return u8(position) | (u8(position + 1) << 8); } + /** 读取 little-endian 无符号 32 位值。 */ + private long u32(long position) throws IOException { return (long) u16(position) | ((long) u16(position + 2) << 16); } + + /** 读取一个有界 Res_value,只保存类型和原始 32 位值。 */ + private Value value(long position, long end) throws IOException { + range(position, 8, end); + int size = u16(position); + require(size >= 8); + range(position, size, end); + return new Value(u8(position + 3), u32(position + 4)); + } + + /** 读取已限定到业务字段容量的字节,不接受包内的数组计数。 */ + private byte[] bytes(long position, int size) throws IOException { + range(position, size, length); + byte[] result = new byte[size]; + file.seek(position); + file.readFully(result); + return result; + } + + /** 检查资源配置是否为默认配置,缓冲大小固定。 */ + private boolean allZero(long position, long size) throws IOException { + range(position, size, length); + file.seek(position); + byte[] buffer = new byte[4096]; + while (size > 0) { + int count = (int) Math.min(buffer.length, size); + file.readFully(buffer, 0, count); + for (int index = 0; index < count; index++) { + if (buffer[index] != 0) { return false; } + } + size -= count; + } + return true; + } + + /** 校验每个 chunk 的头部与实际数据边界。 */ + private Chunk chunk(long position, long end) throws IOException { + range(position, 8, end); + int type = u16(position); + int headerSize = u16(position + 2); + long size = u32(position + 4); + require(headerSize >= 8 && size >= headerSize); + range(position, size, end); + return new Chunk(position, position + size, type, headerSize); + } + + /** 关闭随机访问句柄,并始终清理临时条目。 */ + @Override + public void close() throws IOException { + try { file.close(); } finally { Files.deleteIfExists(path); } + } + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionPackageParser.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionPackageParser.java new file mode 100644 index 0000000..8dd51df --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionPackageParser.java @@ -0,0 +1,136 @@ +package cn.iocoder.yudao.module.member.service.appversion; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.member.dal.dataobject.appversion.AppVersionPackageDO; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Locale; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** 读取真实 APK/WGT 清单并在文件流上计算摘要,禁止从管理员提供的 URL 下载。 */ +@Component +public class AppVersionPackageParser { + /** 当前项目唯一 AppID,可在部署时覆盖。 */ + @Value("${yudao.member.app-version.app-id:__UNI__0A84F60}") + private String expectedAppId = "__UNI__0A84F60"; + /** 编译清单最大读取长度,防止异常 ZIP 条目占满内存。 */ + private static final int MANIFEST_LIMIT = 1024 * 1024; + /** 安装包文件上限,单位字节。 */ + public static final long MAX_PACKAGE_BYTES = 512L * 1024 * 1024; + + /** 校验文件类型、平台、AppID、资源和原生版本,返回尚未持久化的可信元数据。 */ + public AppVersionPackageDO parse(File source, String fileName, String platform) throws Exception { + if (!source.isFile() || source.length() <= 0 || source.length() > MAX_PACKAGE_BYTES) { + throw new IllegalArgumentException("安装包不能为空,且不能超过 512MB"); + } + if (!"android".equals(platform) && !"ios".equals(platform) && !"harmony".equals(platform)) { + throw new IllegalArgumentException("不支持的目标平台"); + } + String safeName = new File(fileName == null ? "" : fileName.replace('\\', '/')).getName(); + if (safeName.isEmpty() || safeName.length() > 255) { + throw new IllegalArgumentException("安装包文件名不能为空且不能超过 255 字符"); + } + String lowerName = safeName.toLowerCase(Locale.ROOT); + String type = lowerName.endsWith(".apk") ? "apk" : lowerName.endsWith(".wgt") ? "wgt" : null; + if (type == null || ("apk".equals(type) && !"android".equals(platform))) { + throw new IllegalArgumentException("Android 支持 APK/WGT,iOS 和 Harmony 支持 WGT"); + } + AppVersionPackageDO result = new AppVersionPackageDO().setPlatform(platform).setPackageType(type) + .setFileName(safeName).setFileSize(source.length()); + try (ZipFile zip = new ZipFile(source)) { + String manifestPath = "apk".equals(type) + ? "assets/apps/" + expectedAppId + "/www/manifest.json" : "manifest.json"; + JsonNode manifest = readManifest(zip, manifestPath); + String appId = manifest.path("id").asText(manifest.path("appid").asText()); + if (!expectedAppId.equals(appId)) { + throw new IllegalArgumentException("安装包 AppID 与当前应用不一致"); + } + JsonNode version = manifest.path("version"); + int resourceCode = positiveCode(version.path("code").asText(manifest.path("versionCode").asText())); + String resourceName = version.path("name").asText(manifest.path("versionName").asText()); + if (resourceName.trim().isEmpty() || resourceName.length() > 100) { + throw new IllegalArgumentException("资源版本名称缺失或超过 100 字符"); + } + result.setAppId(appId).setResourceVersionCode(resourceCode).setVersionCode(resourceCode).setVersionName(resourceName); + } + if ("apk".equals(type)) { + try (ZipFile zip = new ZipFile(source)) { + AndroidManifestReader.read(zip, result); + } + } + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + MessageDigest md5 = MessageDigest.getInstance("MD5"); + try (InputStream input = new FileInputStream(source)) { + byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = input.read(buffer)) != -1) { + sha256.update(buffer, 0, read); + md5.update(buffer, 0, read); + } + } + return result.setSha256(hex(sha256.digest())).setMd5(hex(md5.digest())); + } + + /** 仅读取指定编译清单,不解压 ZIP,限制压缩条目的实际解压长度。 */ + private JsonNode readManifest(ZipFile zip, String path) throws IOException { + byte[] bytes = readEntry(zip, path, MANIFEST_LIMIT); + if (bytes == null) { + throw new IllegalArgumentException("未找到可识别的 uni-app 编译清单,请上传正式 APK 或 WGT 包"); + } + JsonNode result = JsonUtils.parseTree(new String(bytes, StandardCharsets.UTF_8).replaceFirst("^\uFEFF", "")); + if (result == null || !result.isObject()) { + throw new IllegalArgumentException("安装包编译清单格式无效"); + } + return result; + } + + /** ZIP 条目不存在时返回空,存在时同时校验声明大小和实际解压长度。 */ + private byte[] readEntry(ZipFile zip, String path, int limit) throws IOException { + ZipEntry entry = zip.getEntry(path); + if (entry == null || entry.isDirectory()) { + return null; + } + if (entry.getSize() > limit) { + throw new IllegalArgumentException("安装包清单或资源索引超过允许大小"); + } + try (InputStream input = zip.getInputStream(entry); ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + if (output.size() + read > limit) { + throw new IllegalArgumentException("安装包清单超过允许大小"); + } + output.write(buffer, 0, read); + } + return output.toByteArray(); + } + } + + /** 版本码使用客户端可安全比较的正 int,拒绝小数、负数和溢出。 */ + private int positiveCode(String text) { + try { + int code = Integer.parseInt(text); + if (code > 0) { + return code; + } + } catch (NumberFormatException ignored) { + // 统一交由下面的业务校验提示处理。 + } + throw new IllegalArgumentException("安装包版本码必须是正整数且不超过 2147483647"); + } + + /** 摘要统一转为小写十六进制,供客户端下载校验。 */ + private String hex(byte[] bytes) { + StringBuilder text = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + text.append(Character.forDigit((value >>> 4) & 15, 16)).append(Character.forDigit(value & 15, 16)); + } + return text.toString(); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionService.java new file mode 100644 index 0000000..dfbe3ba --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionService.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.member.service.appversion; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.member.controller.admin.appversion.vo.*; +import cn.iocoder.yudao.module.member.controller.app.appversion.vo.AppVersionCheckReqVO; +import org.springframework.web.multipart.MultipartFile; + +/** App 版本管理、可信上传和按真实会员身份检查更新。 */ +public interface AppVersionService { + /** 上传并校验正式安装包,返回可信包记录。 */ + AppVersionPackageRespVO upload(MultipartFile file, String platform); + /** 创建草稿,包字段从可信包记录取得。 */ + Long create(AppVersionSaveReqVO request); + /** 编辑草稿或下架记录并事务替换名单。 */ + void update(AppVersionSaveReqVO request); + /** 删除可编辑记录,保留文件历史。 */ + void delete(Long id); + /** 查询后台详情及灰测名单。 */ + AppVersionRespVO get(Long id); + /** 按编号倒序查询版本页。 */ + PageResult page(AppVersionPageReqVO request); + /** 分页查询当前租户正常会员的最小展示信息。 */ + PageResult memberPage(AppVersionMemberPageReqVO request); + /** 在行锁事务中检查包与名单并发布,支持安全重试。 */ + void publish(Long id); + /** 下架已发布记录,后续 check/validate 立即失效。 */ + void offline(Long id); + /** 将已发布灰测记录事务转换为全量。 */ + void promote(Long id); + /** 将源版本名单复制到目标草稿或下架记录。 */ + void copyMembers(Long id, Long sourceVersionId); + /** 返回当前平台、基座、资源和登录会员可用的最佳更新。 */ + AppVersionRespVO check(AppVersionCheckReqVO request); + /** 下载安装前重算最佳候选,仅目标仍有效时返回元数据。 */ + AppVersionRespVO validate(AppVersionCheckReqVO request); +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionServiceImpl.java new file mode 100644 index 0000000..a137232 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionServiceImpl.java @@ -0,0 +1,460 @@ +package cn.iocoder.yudao.module.member.service.appversion; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.module.infra.api.file.FileApi; +import cn.iocoder.yudao.module.member.controller.admin.appversion.vo.*; +import cn.iocoder.yudao.module.member.controller.app.appversion.vo.AppVersionCheckReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.appversion.*; +import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO; +import cn.iocoder.yudao.module.member.dal.mysql.appversion.*; +import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.multipart.MultipartFile; + +import javax.annotation.Resource; +import java.io.File; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.*; + +/** 版本发布事务与候选选择,租户隔离完全复用既有 Mapper 拦截器。 */ +@Service +@Validated +@Slf4j +public class AppVersionServiceImpl implements AppVersionService { + /** 会员查询与写入的单批大小,避免超长 IN 参数及单条批量语句。 */ + private static final int MEMBER_BATCH_SIZE = 1000; + /** 目标平台的固定保存顺序,保证 CSV 与数组响应一致。 */ + private static final List PLATFORM_ORDER = Collections.unmodifiableList(Arrays.asList("android", "ios", "harmony")); + /** 版本持久化与行锁。 */ + @Resource private AppVersionMapper versionMapper; + /** 已校验包持久化。 */ + @Resource private AppVersionPackageMapper packageMapper; + /** 每版本会员名单。 */ + @Resource private AppVersionMemberMapper versionMemberMapper; + /** 同租户会员查询,不聚合分销、地址等无关数据。 */ + @Resource private MemberUserMapper memberUserMapper; + /** 复用现有文件存储。 */ + @Resource private FileApi fileApi; + /** 真实包元数据解析。 */ + @Resource private AppVersionPackageParser packageParser; + + /** 临时文件从 multipart 转存后以流方式校验、上传,最终始终清理。 */ + @Override + public AppVersionPackageRespVO upload(MultipartFile file, String platform) { + if (file == null || file.isEmpty() || file.getSize() > AppVersionPackageParser.MAX_PACKAGE_BYTES) { + throw exception(APP_VERSION_PACKAGE_INVALID, "文件不能为空且不能超过 512MB"); + } + File temporary = null; + try { + boolean apk = StrUtil.endWithIgnoreCase(file.getOriginalFilename(), ".apk"); + List targets = StrUtil.isBlank(platform) + ? (apk ? Collections.singletonList("android") : Arrays.asList("android", "ios")) + : normalizePlatforms(Arrays.asList(platform.split(",", -1))); + validatePackagePlatforms(apk ? "apk" : "wgt", targets); + temporary = File.createTempFile("app-version-", ".upload"); + file.transferTo(temporary); + AppVersionPackageDO pack; + try { + pack = packageParser.parse(temporary, file.getOriginalFilename(), targets.get(0)); + pack.setPlatform(String.join(",", targets)); + } catch (IllegalArgumentException invalidPackage) { + throw invalidPackage; + } catch (Exception invalidPackage) { + log.warn("[upload][无法识别安装包编译清单,platform={}]", platform, invalidPackage); + throw exception(APP_VERSION_PACKAGE_INVALID, "无法识别安装包或编译清单已损坏"); + } + String path = "version-packages/" + UUID.randomUUID().toString().replace("-", "") + "." + pack.getPackageType(); + String storedUrl = fileApi.createFile(pack.getFileName(), path, temporary, + "apk".equals(pack.getPackageType()) ? "application/vnd.android.package-archive" : "application/octet-stream"); + pack.setDownloadUrl(storedUrl); + try { + packageMapper.insert(pack); + } catch (RuntimeException insertFailure) { + try { + fileApi.deleteVersionPackageFile(storedUrl); + } catch (RuntimeException cleanupFailure) { + insertFailure.addSuppressed(cleanupFailure); + } + throw insertFailure; + } + return BeanUtils.toBean(pack, AppVersionPackageRespVO.class).setPlatforms(targets); + } catch (ServiceException ex) { + throw ex; + } catch (IllegalArgumentException ex) { + throw exception(APP_VERSION_PACKAGE_INVALID, ex.getMessage()); + } catch (Exception ex) { + log.error("[upload][App版本包上传失败,platform={}]", platform, ex); + throw exception(APP_VERSION_UPLOAD_FAILED); + } finally { + if (temporary != null) { + try { + Files.deleteIfExists(temporary.toPath()); + } catch (java.io.IOException ignored) { + temporary.deleteOnExit(); + } + } + } + } + + /** 新建始终为草稿,唯一索引防止并发创建重复版本。 */ + @Override + @Transactional(rollbackFor = Exception.class) + public Long create(AppVersionSaveReqVO request) { + AppVersionDO version = buildVersion(request, null); + version.setId(null).setStatus("draft"); + try { + versionMapper.insert(version); + } catch (DuplicateKeyException ex) { + throw exception(APP_VERSION_DUPLICATE); + } + replaceMembers(version.getId(), request.getReleaseScope(), request.getMemberIds()); + return version.getId(); + } + + /** 编辑和名单替换持有同一版本行锁,发布中的记录需要先下架。 */ + @Override + @Transactional(rollbackFor = Exception.class) + public void update(AppVersionSaveReqVO request) { + AppVersionDO existing = lockEditable(request.getId()); + AppVersionDO version = buildVersion(request, existing).setId(existing.getId()).setStatus(existing.getStatus()); + try { + versionMapper.update(version, new LambdaUpdateWrapper().eq(AppVersionDO::getId, version.getId()) + .set(version.getMaxNativeVersionCode() == null, AppVersionDO::getMaxNativeVersionCode, null) + .set(version.getMinNativeVersionCode() == null, AppVersionDO::getMinNativeVersionCode, null) + .set(version.getNativeVersionCode() == null, AppVersionDO::getNativeVersionCode, null) + .set(version.getPackageName() == null, AppVersionDO::getPackageName, null)); + } catch (DuplicateKeyException ex) { + throw exception(APP_VERSION_DUPLICATE); + } + replaceMembers(existing.getId(), request.getReleaseScope(), request.getMemberIds()); + } + + /** 删除草稿或下架记录,已上传包仍保留以支持历史核查。 */ + @Override + @Transactional(rollbackFor = Exception.class) + public void delete(Long id) { + lockEditable(id); + versionMemberMapper.delete(AppVersionMemberDO::getVersionId, id); + versionMapper.deleteById(id); + } + + /** 详情批量还原会员信息,保留已禁用会员以供管理员识别并移除。 */ + @Override + public AppVersionRespVO get(Long id) { + AppVersionDO version = requireVersion(id); + AppVersionRespVO result = response(version, requirePackage(version.getPackageId())); + List ids = memberIds(id); + result.setMemberIds(ids).setMemberCount(ids.size()); + result.setMembers(ids.isEmpty() ? Collections.emptyList() + : BeanUtils.toBean(loadMembers(ids), AppVersionMemberRespVO.class)); + return result; + } + + /** 一次批量读取包和名单,避免页面逐行查询。 */ + @Override + public PageResult page(AppVersionPageReqVO request) { + PageResult page = versionMapper.selectPage(request); + if (page.getList().isEmpty()) { + return new PageResult<>(Collections.emptyList(), page.getTotal()); + } + Set packageIds = page.getList().stream().map(AppVersionDO::getPackageId).collect(Collectors.toSet()); + Map packages = packageMapper.selectBatchIds(packageIds).stream() + .collect(Collectors.toMap(AppVersionPackageDO::getId, Function.identity())); + Set ids = page.getList().stream().map(AppVersionDO::getId).collect(Collectors.toSet()); + Map counts = versionMemberMapper.selectCounts(ids).stream() + .collect(Collectors.toMap(AppVersionMemberMapper.MemberCount::getVersionId, AppVersionMemberMapper.MemberCount::getMemberCount)); + List list = page.getList().stream().map(version -> response(version, packages.get(version.getPackageId())) + .setMemberCount(counts.getOrDefault(version.getId(), 0L).intValue())).collect(Collectors.toList()); + return new PageResult<>(list, page.getTotal()); + } + + /** 单个关键词匹配会员业务 ID、昵称、手机号或内部主键。 */ + @Override + public PageResult memberPage(AppVersionMemberPageReqVO request) { + LambdaQueryWrapper query = new LambdaQueryWrapper() + .select(MemberUserDO::getId, MemberUserDO::getUserCode, MemberUserDO::getNickname, + MemberUserDO::getAvatar, MemberUserDO::getMobile, MemberUserDO::getStatus) + .eq(MemberUserDO::getStatus, CommonStatusEnum.ENABLE.getStatus()); + String keyword = StrUtil.trim(request.getKeyword()); + if (StrUtil.isNotBlank(keyword)) { + query.and(q -> { + q.like(MemberUserDO::getUserCode, keyword).or().like(MemberUserDO::getNickname, keyword) + .or().like(MemberUserDO::getMobile, keyword); + if (keyword.matches("[0-9]{1,18}")) { + q.or().eq(MemberUserDO::getId, Long.valueOf(keyword)); + } + }); + } + PageResult page = memberUserMapper.selectPage(request, query.orderByDesc(MemberUserDO::getId)); + return new PageResult<>(BeanUtils.toBean(page.getList(), AppVersionMemberRespVO.class), page.getTotal()); + } + + /** 发布前再次检查包和名单,允许重复点击发布。 */ + @Override + @Transactional(rollbackFor = Exception.class) + public void publish(Long id) { + AppVersionDO version = lockVersion(id); + if ("published".equals(version.getStatus())) { + return; + } + requirePackage(version.getPackageId()); + validateMembers(version.getReleaseScope(), memberIds(id)); + versionMapper.updateById(new AppVersionDO().setId(id).setStatus("published").setPublishTime(LocalDateTime.now())); + } + + /** 下架是可重试的状态变更,后续验证会立即重新读取数据库。 */ + @Override + @Transactional(rollbackFor = Exception.class) + public void offline(Long id) { + AppVersionDO version = lockVersion(id); + if ("draft".equals(version.getStatus())) { + throw exception(APP_VERSION_STATE_INVALID); + } + versionMapper.updateById(new AppVersionDO().setId(id).setStatus("offline")); + } + + /** 灰测转全量原子更新范围并移除名单,失败时整体回滚。 */ + @Override + @Transactional(rollbackFor = Exception.class) + public void promote(Long id) { + AppVersionDO version = lockVersion(id); + if (!"published".equals(version.getStatus())) { + throw exception(APP_VERSION_STATE_INVALID); + } + versionMapper.updateById(new AppVersionDO().setId(id).setReleaseScope("all")); + versionMemberMapper.delete(AppVersionMemberDO::getVersionId, id); + } + + /** 目标版本加锁后复制源名单,源必须存在且为指定会员版本。 */ + @Override + @Transactional(rollbackFor = Exception.class) + public void copyMembers(Long id, Long sourceVersionId) { + lockEditable(id); + AppVersionDO source = requireVersion(sourceVersionId); + if (!"members".equals(source.getReleaseScope())) { + throw exception(APP_VERSION_MEMBERS_INVALID); + } + List ids = memberIds(sourceVersionId); + validateMembers("members", ids); + versionMapper.updateById(new AppVersionDO().setId(id).setReleaseScope("members")); + replaceMembers(id, "members", ids); + } + + /** 仅根据服务端 token 的正常会员身份加入灰测候选,无有效身份只筛选全量。 */ + @Override + public AppVersionRespVO check(AppVersionCheckReqVO request) { + LoginUser login = SecurityFrameworkUtils.getLoginUser(); + Long memberId = null; + if (login != null && UserTypeEnum.MEMBER.getValue().equals(login.getUserType())) { + MemberUserDO member = memberUserMapper.selectById(login.getId()); + if (member != null && CommonStatusEnum.ENABLE.getStatus().equals(member.getStatus())) { + memberId = member.getId(); + } + } + LambdaQueryWrapper query = new LambdaQueryWrapper() + .eq(AppVersionDO::getAppId, request.getAppId()) + .apply("FIND_IN_SET({0}, platform) > 0", request.getPlatform()) + .eq(AppVersionDO::getStatus, "published"); + if (memberId == null) { + query.eq(AppVersionDO::getReleaseScope, "all"); + } else { + final Long actualMemberId = memberId; + query.and(q -> q.eq(AppVersionDO::getReleaseScope, "all").or() + .eq(AppVersionDO::getReleaseScope, "members") + .exists("SELECT 1 FROM member_app_version_member vm WHERE vm.version_id = member_app_version.id" + + " AND vm.deleted = 0 AND vm.member_id = {0}", actualMemberId)); + } + AppVersionDO candidate = selectCandidate(versionMapper.selectList(query), request); + if (candidate == null) { + return null; + } + AppVersionRespVO result = response(candidate, requirePackage(candidate.getPackageId())); + // App 端继续收到本次请求的单平台值,多目标列表另由 platforms 提供。 + result.setPlatform(request.getPlatform()); + return result; + } + + /** 撤回、移除名单、切换账号或更高版本上线后,旧目标返回空以便客户端重新检查。 */ + @Override + public AppVersionRespVO validate(AppVersionCheckReqVO request) { + if (request.getId() == null) { + throw exception(APP_VERSION_NOT_EXISTS); + } + AppVersionRespVO result = check(request); + return result != null && request.getId().equals(result.getId()) ? result : null; + } + + /** 先比较资源版本,同资源优先兼容 WGT;APK 必须同时推进基座且保持资源不降级。 */ + AppVersionDO selectCandidate(List versions, AppVersionCheckReqVO request) { + return versions.stream().filter(version -> compatible(version, request)) + .max(Comparator.comparing(AppVersionDO::getResourceVersionCode) + .thenComparing(version -> "wgt".equals(version.getPackageType()) ? 1 : 0) + .thenComparing(version -> version.getNativeVersionCode() == null ? 0 : version.getNativeVersionCode()) + .thenComparing(AppVersionDO::getId)).orElse(null); + } + + /** 安装选择规则分离原生基座与资源版本,兼容范围为包含边界。 */ + private boolean compatible(AppVersionDO version, AppVersionCheckReqVO request) { + if (version.getPlatform() == null || !("," + version.getPlatform() + ",").contains("," + request.getPlatform() + ",")) { + return false; + } + if ("apk".equals(version.getPackageType())) { + return "android".equals(request.getPlatform()) && StrUtil.isNotBlank(request.getPackageName()) + && request.getPackageName().equals(version.getPackageName()) + && version.getNativeVersionCode() > request.getNativeVersionCode() + && version.getResourceVersionCode() >= request.getResourceVersionCode(); + } + return "wgt".equals(version.getPackageType()) && version.getResourceVersionCode() > request.getResourceVersionCode() + && version.getMinNativeVersionCode() != null && request.getNativeVersionCode() >= version.getMinNativeVersionCode() + && (version.getMaxNativeVersionCode() == null || request.getNativeVersionCode() <= version.getMaxNativeVersionCode()); + } + + /** 拒绝缺失、跨租户或已删除的可信包。 */ + private AppVersionPackageDO requirePackage(Long id) { + AppVersionPackageDO pack = packageMapper.selectById(id); + if (pack == null || StrUtil.isBlank(pack.getDownloadUrl())) { + throw exception(APP_VERSION_PACKAGE_NOT_EXISTS); + } + return pack; + } + + /** 由可信包创建快照,忽略客户端自填包元数据。 */ + private AppVersionDO buildVersion(AppVersionSaveReqVO request, AppVersionDO existing) { + AppVersionPackageDO pack = requirePackage(request.getPackageId()); + List targets; + try { + String previous = existing == null ? pack.getPlatform() : existing.getPlatform(); + targets = normalizePlatforms(request.getPlatforms() == null ? Arrays.asList(previous.split(",", -1)) : request.getPlatforms()); + validatePackagePlatforms(pack.getPackageType(), targets); + } catch (IllegalArgumentException invalidPlatforms) { + throw exception(APP_VERSION_PLATFORMS_INVALID); + } + if ("wgt".equals(pack.getPackageType()) && (request.getMinNativeVersionCode() == null + || (request.getMaxNativeVersionCode() != null && request.getMaxNativeVersionCode() < request.getMinNativeVersionCode()))) { + throw exception(APP_VERSION_NATIVE_RANGE_INVALID); + } + validateMembers(request.getReleaseScope(), request.getMemberIds()); + AppVersionDO version = new AppVersionDO().setAppId(pack.getAppId()).setPlatform(String.join(",", targets)) + .setPackageType(pack.getPackageType()).setVersionCode(pack.getVersionCode()).setVersionName(pack.getVersionName()) + .setNativeVersionCode(pack.getNativeVersionCode()).setResourceVersionCode(pack.getResourceVersionCode()) + .setPackageName(pack.getPackageName()); + version.setPackageId(pack.getId()).setUpdateContent(request.getUpdateContent()) + .setForceUpdate(request.getForceUpdate()).setReleaseScope(request.getReleaseScope()); + if ("wgt".equals(pack.getPackageType())) { + version.setMinNativeVersionCode(request.getMinNativeVersionCode()).setMaxNativeVersionCode(request.getMaxNativeVersionCode()); + } + return version; + } + + /** 名单必须包含同租户正常会员,重复主键按一次处理。 */ + private void validateMembers(String scope, List ids) { + if (!"members".equals(scope)) { + return; + } + if (ids == null || ids.isEmpty()) { + throw exception(APP_VERSION_MEMBERS_INVALID); + } + Set unique = new LinkedHashSet<>(ids); + if (unique.contains(null)) { + throw exception(APP_VERSION_MEMBERS_INVALID); + } + List members = loadMembers(new ArrayList<>(unique)); + if (members.size() != unique.size() || members.stream().anyMatch(member -> !CommonStatusEnum.ENABLE.getStatus().equals(member.getStatus()))) { + throw exception(APP_VERSION_MEMBERS_INVALID); + } + } + + /** 分批读取同租户会员,用于大名单校验与详情返显。 */ + private List loadMembers(List ids) { + List members = new ArrayList<>(); + for (int start = 0; start < ids.size(); start += MEMBER_BATCH_SIZE) { + members.addAll(memberUserMapper.selectBatchIds(ids.subList(start, Math.min(start + MEMBER_BATCH_SIZE, ids.size())))); + } + return members; + } + + /** 调用者已持有版本行锁,逻辑删除旧名单后写入新名单。 */ + private void replaceMembers(Long id, String scope, List ids) { + versionMemberMapper.delete(AppVersionMemberDO::getVersionId, id); + if ("members".equals(scope)) { + List rows = new LinkedHashSet<>(ids).stream() + .map(memberId -> new AppVersionMemberDO().setVersionId(id).setMemberId(memberId)).collect(Collectors.toList()); + versionMemberMapper.insertBatch(rows, MEMBER_BATCH_SIZE); + } + } + + /** 读取版本的有效灰测会员主键。 */ + private List memberIds(Long id) { + return versionMemberMapper.selectList(AppVersionMemberDO::getVersionId, id).stream() + .map(AppVersionMemberDO::getMemberId).collect(Collectors.toList()); + } + + /** 查询有效版本,跨租户主键由拦截器自然过滤。 */ + private AppVersionDO requireVersion(Long id) { + AppVersionDO version = id == null ? null : versionMapper.selectById(id); + if (version == null) { + throw exception(APP_VERSION_NOT_EXISTS); + } + return version; + } + + /** 状态操作使用数据库行锁,事务注解位于公开入口。 */ + private AppVersionDO lockVersion(Long id) { + AppVersionDO version = id == null ? null : versionMapper.selectForUpdate(id); + if (version == null) { + throw exception(APP_VERSION_NOT_EXISTS); + } + return version; + } + + /** 发布中的版本通过下架进入可编辑状态。 */ + private AppVersionDO lockEditable(Long id) { + AppVersionDO version = lockVersion(id); + if ("published".equals(version.getStatus())) { + throw exception(APP_VERSION_STATE_INVALID); + } + return version; + } + + /** 发布记录覆盖包编号与策略字段,响应的 id 始终是版本发布编号。 */ + private AppVersionRespVO response(AppVersionDO version, AppVersionPackageDO pack) { + AppVersionRespVO result = pack == null ? new AppVersionRespVO() : BeanUtils.toBean(pack, AppVersionRespVO.class); + org.springframework.beans.BeanUtils.copyProperties(version, result); + result.setPlatforms(normalizePlatforms(Arrays.asList(version.getPlatform().split(",", -1)))); + return result; + } + + /** 校验并去重目标平台,始终按固定顺序保存,拒绝空列表和未知平台。 */ + private List normalizePlatforms(Collection targets) { + if (targets == null || targets.isEmpty() || targets.stream().anyMatch(value -> !PLATFORM_ORDER.contains(value))) { + throw new IllegalArgumentException("请选择有效的目标平台"); + } + return PLATFORM_ORDER.stream().filter(targets::contains).collect(Collectors.toList()); + } + + /** APK 只能安装到 Android,WGT 允许共享多个发布目标。 */ + private void validatePackagePlatforms(String packageType, List targets) { + if ("apk".equals(packageType) && !Collections.singletonList("android").equals(targets)) { + throw new IllegalArgumentException("APK 的发布目标只能为 Android"); + } + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/ImPrivatePushEvent.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/ImPrivatePushEvent.java new file mode 100644 index 0000000..5e8f011 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/ImPrivatePushEvent.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.member.service.push; + +import lombok.Data; +import javax.validation.constraints.*; + +@Data +public class ImPrivatePushEvent { + @NotBlank @Size(max = 80) + private String eventId; + @NotNull @Positive + private Long messageId; + @NotNull @Positive + private Long sendId; + @NotNull @Positive + private Long recvId; + @NotNull @Min(0) @Max(4) + private Integer type; + @NotNull @Size(max = 10000) + private String content; + @NotNull @Positive + private Long sendTime; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/JPushGateway.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/JPushGateway.java new file mode 100644 index 0000000..1e51ccd --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/JPushGateway.java @@ -0,0 +1,133 @@ +package cn.iocoder.yudao.module.member.service.push; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushRecordDO; +import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushTaskDO; +import cn.iocoder.yudao.module.member.framework.web.config.JPushProperties; +import cn.jiguang.common.ClientConfig; +import cn.jiguang.common.resp.APIConnectionException; +import cn.jiguang.common.resp.APIRequestException; +import cn.jpush.api.JPushClient; +import cn.jpush.api.push.model.*; +import cn.jpush.api.push.model.audience.Audience; +import cn.jpush.api.push.model.notification.*; +import lombok.Getter; +import org.springframework.stereotype.Component; +import javax.annotation.PreDestroy; +import javax.annotation.Resource; +import java.util.*; +import java.util.stream.Collectors; + +@Component +public class JPushGateway { + public static final String ANDROID_CHANNEL_ID = "htys_message"; + @Resource + private JPushProperties properties; + private JPushClient client; + + public String getAppKey() { + return StrUtil.trimToEmpty(properties.getAppKey()); + } + + private synchronized JPushClient client() { + if (!properties.isEnabled() || StrUtil.isBlank(properties.getAppKey()) + || StrUtil.isBlank(properties.getMasterSecret())) { + throw new PushFailure("CONFIG_DISABLED", "极光推送未启用或配置不完整", false, false); + } + if (client == null) { + ClientConfig config = ClientConfig.getInstance(); + config.setMaxRetryTimes(0); + config.setConnectionTimeout(5000); + config.setReadTimeout(10000); + client = new JPushClient(properties.getMasterSecret(), properties.getAppKey(), null, config); + } + return client; + } + + public String allocateCid() { + try { + return client().getCidList(1, "push").cidlist.get(0); + } catch (APIConnectionException e) { + throw new PushFailure("CID_CONNECTION", "获取极光 CID 连接失败", true, false); + } catch (APIRequestException e) { + throw requestFailure(e); + } + } + + public String payload(MemberPushTaskDO task, List records, String cid) { + Map extras = new LinkedHashMap<>(); + extras.put("taskId", String.valueOf(task.getId())); + extras.put("type", "manual"); + if ("AUTO".equals(task.getSource())) { + extras.put("type", "private_message"); + extras.put("messageId", String.valueOf(task.getMessageId())); + extras.put("senderId", String.valueOf(task.getSenderId())); + extras.put("receiverId", String.valueOf(task.getReceiverId())); + extras.put("chatType", "PRIVATE"); + extras.put("targetId", String.valueOf(task.getSenderId())); + } + // iOS alert 使用字典以同时展示标题和正文。业务路由字段放入两端通知 extras。 + Map iosAlert = new LinkedHashMap<>(); + iosAlert.put("title", task.getTitle()); + iosAlert.put("body", task.getContent()); + com.google.gson.JsonObject alert = new com.google.gson.JsonObject(); + iosAlert.forEach(alert::addProperty); + PushPayload payload = PushPayload.newBuilder() + .setCid(cid) + .setPlatform(Platform.android_ios()) + .setAudience(Audience.registrationId(records.stream() + .map(MemberPushRecordDO::getRegistrationId).collect(Collectors.toList()))) + .setNotification(Notification.newBuilder() + .setAlert(task.getContent()) + .addPlatformNotification(AndroidNotification.newBuilder() + .setTitle(task.getTitle()).setChannelId(ANDROID_CHANNEL_ID) + .setAlertType(-1).addExtras(extras).build()) + .addPlatformNotification(IosNotification.newBuilder() + .setAlert(alert).setSound("default").incrBadge(1).addExtras(extras).build()) + .build()) + .setOptions(Options.newBuilder().setApnsProduction(properties.isApnsProduction()) + .setTimeToLive(Math.max(1, java.time.Duration.between( + java.time.LocalDateTime.now(), task.getExpiresAt()).getSeconds())).build()) + .build(); + return payload.toString(); + } + + public String send(String payload) { + try { + return String.valueOf(client().sendPush(payload).msg_id); + } catch (APIConnectionException e) { + // 请求可能已经被极光受理,恢复时必须使用持久化的原始 payload 和 CID。 + throw new PushFailure("CONNECTION_UNCERTAIN", "连接中断,正在确认极光提交结果", true, true); + } catch (APIRequestException e) { + throw requestFailure(e); + } + } + + private PushFailure requestFailure(APIRequestException e) { + boolean retryable = e.getStatus() == 429 || e.getStatus() >= 500; + return new PushFailure(String.valueOf(e.getErrorCode()), + StrUtil.maxLength(StrUtil.blankToDefault(e.getErrorMessage(), "极光拒绝请求"), 1000), + retryable, e.getStatus() >= 500); + } + + @PreDestroy + public synchronized void close() { + if (client != null) { + client.close(); + } + } + + @Getter + public static class PushFailure extends RuntimeException { + private final String code; + private final boolean retryable; + private final boolean uncertain; + + public PushFailure(String code, String message, boolean retryable, boolean uncertain) { + super(message); + this.code = code; + this.retryable = retryable; + this.uncertain = uncertain; + } + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushDeviceServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushDeviceServiceImpl.java index 7a82897..12da95c 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushDeviceServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushDeviceServiceImpl.java @@ -1,14 +1,19 @@ package cn.iocoder.yudao.module.member.service.push; import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.module.member.controller.app.push.vo.AppPushBindReqVO; import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushDeviceDO; import cn.iocoder.yudao.module.member.dal.mysql.push.MemberPushDeviceMapper; +import cn.iocoder.yudao.module.member.framework.web.config.JPushProperties; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import javax.annotation.Resource; import java.util.List; +import java.util.Locale; +import java.util.Optional; @Service @Validated @@ -17,33 +22,22 @@ public class MemberPushDeviceServiceImpl implements MemberPushDeviceService { @Resource private MemberPushDeviceMapper pushDeviceMapper; + @Resource + private JPushProperties jPushProperties; + @Override public void bind(Long memberId, AppPushBindReqVO reqVO) { - String registrationId = reqVO.getRegistrationId().trim(); - String provider = StrUtil.blankToDefault(reqVO.getProvider(), "jpush"); - String platform = reqVO.getPlatform().trim().toLowerCase(); - String alias = StrUtil.blankToDefault(reqVO.getAlias(), "u" + memberId); - - MemberPushDeviceDO existing = pushDeviceMapper.selectByRegistrationId(registrationId); - if (existing == null) { - MemberPushDeviceDO row = MemberPushDeviceDO.builder() - .memberId(memberId) - .registrationId(registrationId) - .alias(alias) - .platform(platform) - .provider(provider) - .build(); - pushDeviceMapper.insert(row); - return; + String appKey = StrUtil.trimToEmpty(reqVO.getAppKey()); + if (StrUtil.isNotEmpty(appKey) && !appKey.equals(StrUtil.trimToEmpty(jPushProperties.getAppKey()))) { + throw new ServiceException(400, "设备 AppKey 与当前极光应用不匹配"); } - - MemberPushDeviceDO update = new MemberPushDeviceDO(); - update.setId(existing.getId()); - update.setMemberId(memberId); - update.setAlias(alias); - update.setPlatform(platform); - update.setProvider(provider); - pushDeviceMapper.updateById(update); + String registrationId = reqVO.getRegistrationId().trim(); + MemberPushDeviceDO device = MemberPushDeviceDO.builder() + .memberId(memberId).registrationId(registrationId).alias("u" + memberId) + .platform(reqVO.getPlatform().trim().toLowerCase(Locale.ROOT)).provider("jpush") + .appKey(appKey).build(); + device.setTenantId(Optional.ofNullable(TenantContextHolder.getTenantId()).orElse(0L)); + pushDeviceMapper.bindDevice(device); } @Override @@ -51,20 +45,12 @@ public class MemberPushDeviceServiceImpl implements MemberPushDeviceService { if (StrUtil.isBlank(registrationId)) { return; } - MemberPushDeviceDO existing = pushDeviceMapper.selectByRegistrationId(registrationId.trim()); - if (existing == null) { - return; - } - // 仅允许解绑自己的设备,避免误删他人绑定 - if (!memberId.equals(existing.getMemberId())) { - return; - } - pushDeviceMapper.physicalDeleteById(existing.getId()); + pushDeviceMapper.unbindDevice(memberId, registrationId.trim()); } @Override public List getByMemberId(Long memberId) { - return pushDeviceMapper.selectListByMemberId(memberId); + return pushDeviceMapper.selectListByMemberId(memberId, StrUtil.trimToEmpty(jPushProperties.getAppKey())); } } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushService.java deleted file mode 100644 index c28a2a5..0000000 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushService.java +++ /dev/null @@ -1,12 +0,0 @@ -package cn.iocoder.yudao.module.member.service.push; - -import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatPrivateMessageSendReqVO; - -public interface MemberPushService { - - /** - * 推送私聊消息通知 - */ - void pushPrivateMessage(ChatPrivateMessageSendReqVO message); - -} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushServiceImpl.java deleted file mode 100644 index f5451e4..0000000 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushServiceImpl.java +++ /dev/null @@ -1,118 +0,0 @@ -package cn.iocoder.yudao.module.member.service.push; - -import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.util.StrUtil; -import cn.iocoder.yudao.module.member.api.user.MemberUserApi; -import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; -import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatPrivateMessageSendReqVO; -import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushDeviceDO; -import cn.iocoder.yudao.module.member.framework.web.config.JPushProperties; -import cn.jpush.api.JPushClient; -import cn.jpush.api.push.PushResult; -import cn.jpush.api.push.model.Message; -import cn.jpush.api.push.model.Options; -import cn.jpush.api.push.model.Platform; -import cn.jpush.api.push.model.PushPayload; -import cn.jpush.api.push.model.audience.Audience; -import cn.jpush.api.push.model.notification.AndroidNotification; -import cn.jpush.api.push.model.notification.IosNotification; -import cn.jpush.api.push.model.notification.Notification; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import javax.annotation.Resource; -import java.util.List; -import java.util.stream.Collectors; - -@Slf4j -@Service -public class MemberPushServiceImpl implements MemberPushService { - - private static final int MAX_ALERT_LENGTH = 100; - /** Android 8+ 通知渠道,需与 App 端 JPush setChannelAndSound 的 channel_id 一致 */ - private static final String ANDROID_CHANNEL_ID = "htys_message"; - /** iOS 系统默认提示音 */ - private static final String IOS_SOUND = "default"; - /** 极光 Android alert_type:-1 表示默认(声音 + 震动等) */ - private static final int ANDROID_ALERT_TYPE_DEFAULT = -1; - - @Resource - private MemberPushDeviceService pushDeviceService; - @Resource - private MemberUserApi memberUserApi; - @Resource - private JPushProperties jPushProperties; - - @Override - public void pushPrivateMessage(ChatPrivateMessageSendReqVO message) { - if (!jPushProperties.isEnabled() || StrUtil.isBlank(jPushProperties.getAppKey()) - || StrUtil.isBlank(jPushProperties.getMasterSecret())) { - return; - } - List devices = pushDeviceService.getByMemberId(message.getRecvId()); - List registrationIds = devices.stream() - .filter(device -> StrUtil.equalsIgnoreCase(device.getProvider(), "jpush")) - .map(MemberPushDeviceDO::getRegistrationId) - .filter(StrUtil::isNotBlank) - .distinct() - .collect(Collectors.toList()); - if (CollUtil.isEmpty(registrationIds)) { - return; - } - - MemberUserRespDTO sender = memberUserApi.getUser(message.getSendId()); - String title = sender != null && StrUtil.isNotBlank(sender.getNickname()) ? sender.getNickname() : "新消息"; - String alert = buildAlert(message); - PushPayload payload = PushPayload.newBuilder() - .setPlatform(Platform.android_ios()) - .setAudience(Audience.registrationId(registrationIds)) - .setNotification(Notification.newBuilder() - .setAlert(alert) - .addPlatformNotification(AndroidNotification.newBuilder() - .setTitle(title) - .setChannelId(ANDROID_CHANNEL_ID) - .setAlertType(ANDROID_ALERT_TYPE_DEFAULT) - .build()) - .addPlatformNotification(IosNotification.newBuilder() - .incrBadge(1) - .setSound(IOS_SOUND) - .build()) - .build()) - .setMessage(Message.newBuilder().setTitle(title).setMsgContent(alert) - .addExtra("type", "private_message") - .addExtra("senderId", String.valueOf(message.getSendId())) - .addExtra("receiverId", String.valueOf(message.getRecvId())) - .addExtra("messageType", String.valueOf(message.getType())) - .build()) - .setOptions(Options.newBuilder().setApnsProduction(jPushProperties.isApnsProduction()).build()) - .build(); - try { - JPushClient client = new JPushClient(jPushProperties.getMasterSecret(), jPushProperties.getAppKey()); - PushResult result = client.sendPush(payload); - log.info("[pushPrivateMessage][sendId({}) recvId({}) registrationIds({}) msgId({}) 推送成功]", - message.getSendId(), message.getRecvId(), registrationIds.size(), result.msg_id); - } catch (Exception e) { - log.error("[pushPrivateMessage][sendId({}) recvId({}) 极光推送失败]", - message.getSendId(), message.getRecvId(), e); - } - } - - private String buildAlert(ChatPrivateMessageSendReqVO message) { - if (message.getType() == null || message.getType() == 0) { - return StrUtil.maxLength(message.getContent(), MAX_ALERT_LENGTH); - } - switch (message.getType()) { - case 1: - return "[图片]"; - case 2: - return "[文件]"; - case 3: - return "[语音]"; - case 4: - return "[视频]"; - default: - return "您收到一条新消息"; - } - } - -} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushTaskService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushTaskService.java new file mode 100644 index 0000000..6a4ca83 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushTaskService.java @@ -0,0 +1,623 @@ +package cn.iocoder.yudao.module.member.service.push; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; +import cn.iocoder.yudao.module.member.client.service.ChatApi; +import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivatePushContextRespVO; +import cn.iocoder.yudao.module.member.controller.admin.push.vo.*; +import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserPageReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.push.*; +import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO; +import cn.iocoder.yudao.module.member.dal.mysql.push.*; +import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.digest.DigestUtils; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class MemberPushTaskService { + private static final int BATCH_SIZE = 1000; + private static final long[] RETRY_SECONDS = {5, 30, 120, 600}; + @Resource private MemberPushTaskMapper taskMapper; + @Resource private MemberPushRecordMapper recordMapper; + @Resource private MemberPushDeviceMapper deviceMapper; + @Resource private MemberUserMapper userMapper; + @Resource private ChatApi chatApi; + @Resource private JPushGateway gateway; + @Resource private RedissonClient redisson; + @Resource private TransactionTemplate transactions; + private long lastScanErrorAt; + + public Long createManual(MemberPushSendReqVO req, Long operatorId) { + List memberIds = new ArrayList<>(); + if ("USER".equals(req.getTargetType())) { + if (req.getMemberIds() == null || req.getMemberIds().isEmpty()) { + throw badRequest("请选择接收会员"); + } + memberIds = req.getMemberIds().stream().distinct().sorted().collect(Collectors.toList()); + List users = userMapper.selectBatchIds(memberIds); + if (users.size() != memberIds.size() || users.stream().anyMatch(user -> !isEnabled(user))) { + throw badRequest("接收会员不存在或已停用"); + } + } + String title = req.getTitle().trim(); + String content = req.getContent().trim(); + if (title.isEmpty() || content.isEmpty()) { + throw badRequest("标题和正文不能为空"); + } + String hash = DigestUtils.sha256Hex(JSONUtil.toJsonStr(Arrays.asList( + req.getTargetType(), memberIds, title, content))); + MemberPushTaskDO task = newTask("MANUAL:" + operatorId + ":" + req.getRequestId(), "MANUAL"); + task.setRequestHash(hash); + task.setTargetType(req.getTargetType()); + task.setMemberIds(JSONUtil.toJsonStr(memberIds)); + task.setOperatorId(operatorId); + task.setTitle(title); + task.setContent(content); + return insertIdempotent(task); + } + + public Long acceptPrivate(ImPrivatePushEvent event) { + // 会员 ID 在 IM 中全局唯一,验签后仅在这里查找接收会员所属租户。 + MemberUserDO receiver = TenantUtils.executeIgnore(() -> userMapper.selectById(event.getRecvId())); + if (receiver == null) { + return 0L; + } + Long tenantId = receiver.getTenantId() == null ? 0L : receiver.getTenantId(); + return TenantUtils.execute(tenantId, () -> { + MemberUserDO sender = userMapper.selectById(event.getSendId()); + MemberPushTaskDO task = newTask("IM:" + event.getMessageId(), "AUTO"); + task.setRequestHash(DigestUtils.sha256Hex(JSONUtil.toJsonStr(event))); + task.setTargetType("USER"); + task.setMemberIds(JSONUtil.toJsonStr(Collections.singletonList(event.getRecvId()))); + task.setMessageId(event.getMessageId()); + task.setSenderId(event.getSendId()); + task.setReceiverId(event.getRecvId()); + task.setTitle(StrUtil.maxLength(sender == null ? "新消息" + : StrUtil.blankToDefault(sender.getNickname(), "新消息"), 40)); + task.setContent(alert(event.getType(), event.getContent())); + task.setExpiresAt(java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(event.getSendTime()), + java.time.ZoneId.systemDefault()).plusHours(24)); + return insertIdempotent(task); + }); + } + + private MemberPushTaskDO newTask(String requestKey, String source) { + MemberPushTaskDO task = new MemberPushTaskDO(); + task.setTenantId(Optional.ofNullable(TenantContextHolder.getTenantId()).orElse(0L)); + task.setRequestKey(requestKey); + task.setSource(source); + task.setStatus("WAITING"); + task.setAudienceComplete(false); + task.setAudienceCursor(0L); + MemberPushDeviceDO last = deviceMapper.selectOne(new LambdaQueryWrapper() + .eq(MemberPushDeviceDO::getAppKey, gateway.getAppKey()) + .ne(MemberPushDeviceDO::getAppKey, "") + .eq(MemberPushDeviceDO::getProvider, "jpush") + .orderByDesc(MemberPushDeviceDO::getId).last("LIMIT 1")); + task.setSnapshotMaxId(last == null ? 0L : last.getId()); + task.setTotalMembers(0); + task.setTotalDevices(0); + task.setSubmittedCount(0); + task.setFailedCount(0); + task.setSkippedCount(0); + task.setProcessedCount(0); + task.setAttemptCount(0); + task.setNextRetryTime(LocalDateTime.now()); + task.setExpiresAt(LocalDateTime.now().plusHours(24)); + return task; + } + + private Long insertIdempotent(MemberPushTaskDO task) { + MemberPushTaskDO existing = taskMapper.selectOne(MemberPushTaskDO::getRequestKey, task.getRequestKey()); + if (existing != null) { + return checkExisting(existing, task); + } + try { + taskMapper.insert(task); + return task.getId(); + } catch (DuplicateKeyException e) { + return checkExisting(taskMapper.selectOne(MemberPushTaskDO::getRequestKey, task.getRequestKey()), task); + } + } + + private Long checkExisting(MemberPushTaskDO existing, MemberPushTaskDO requested) { + if (existing == null || !Objects.equals(existing.getRequestHash(), requested.getRequestHash())) { + throw badRequest("相同请求编号对应的推送内容已变化,请重新提交"); + } + return existing.getId(); + } + + public PageResult page(MemberPushPageReqVO req) { + return taskMapper.selectPage(req); + } + + public MemberPushTaskDO get(Long id) { + MemberPushTaskDO task = taskMapper.selectById(id); + if (task == null) { + throw badRequest("推送任务不存在"); + } + return task; + } + + public PageResult records(MemberPushRecordPageReqVO req) { + get(req.getTaskId()); + return recordMapper.selectPage(req, new LambdaQueryWrapper() + .eq(MemberPushRecordDO::getTaskId, req.getTaskId()).orderByAsc(MemberPushRecordDO::getId)); + } + + public PageResult users(MemberUserPageReqVO req) { + req.setStatus(0); + PageResult page = userMapper.selectPage(req); + List users = page.getList().stream().map(user -> { + MemberPushUserRespVO result = new MemberPushUserRespVO(); + result.setId(user.getId()); + result.setNickname(user.getNickname()); + result.setUserCode(user.getUserCode()); + result.setMobile(user.getMobile()); + result.setDeviceCount(deviceMapper.selectCount(new LambdaQueryWrapper() + .eq(MemberPushDeviceDO::getMemberId, user.getId()) + .eq(MemberPushDeviceDO::getAppKey, gateway.getAppKey()) + .ne(MemberPushDeviceDO::getAppKey, "") + .eq(MemberPushDeviceDO::getProvider, "jpush"))); + return result; + }).collect(Collectors.toList()); + return new PageResult<>(users, page.getTotal()); + } + + @Scheduled(fixedDelay = 1000, initialDelay = 5000) + public void dispatchPending() { + try { + for (MemberPushTaskDO task : taskMapper.selectPendingAcrossTenants(LocalDateTime.now())) { + processWithLock(task); + } + } catch (Exception e) { + if (System.currentTimeMillis() - lastScanErrorAt > 60000) { + lastScanErrorAt = System.currentTimeMillis(); + log.error("推送任务扫描失败,请核对推送表迁移及 Redis", e); + } + } + } + + private void processWithLock(MemberPushTaskDO snapshot) { + RLock lock = redisson.getLock("member:push:task:" + snapshot.getId()); + if (!lock.tryLock()) { + return; + } + try { + TenantUtils.execute(snapshot.getTenantId(), () -> process(snapshot.getId())); + } catch (Exception e) { + log.error("推送任务处理失败,taskId={}", snapshot.getId(), e); + } finally { + lock.unlock(); + } + } + + void process(Long taskId) { + MemberPushTaskDO task = get(taskId); + if (!("WAITING".equals(task.getStatus()) || "RUNNING".equals(task.getStatus())) + || task.getNextRetryTime().isAfter(LocalDateTime.now())) { + return; + } + try { + if (!task.getExpiresAt().isAfter(LocalDateTime.now())) { + failTask(task, "EXPIRED", "推送任务已超过24小时有效期"); + return; + } + if (!Boolean.TRUE.equals(task.getAudienceComplete())) { + expandAudience(task); + return; + } + List pending = recordMapper.selectList(new LambdaQueryWrapper() + .eq(MemberPushRecordDO::getTaskId, taskId).in(MemberPushRecordDO::getStatus, "WAITING", "SENDING") + .le(MemberPushRecordDO::getNextRetryTime, LocalDateTime.now()) + .orderByAsc(MemberPushRecordDO::getId).last("LIMIT " + BATCH_SIZE)); + if (pending.isEmpty()) { + refreshSummary(task); + return; + } + MemberPushRecordDO first = pending.get(0); + if ("SENDING".equals(first.getStatus()) && StrUtil.isNotBlank(first.getCid())) { + resumeBatch(task, first.getCid()); + } else { + List fresh = pending.stream() + .filter(record -> "WAITING".equals(record.getStatus())).collect(Collectors.toList()); + List eligible = filterEligible(task, fresh); + if (!eligible.isEmpty()) { + sendNewBatch(task, eligible); + } + } + refreshSummary(task); + } catch (JPushGateway.PushFailure e) { + if (e.isRetryable()) { + deferTask(task, e.getMessage()); + } else { + failTask(task, e.getCode(), e.getMessage()); + } + } catch (Exception e) { + deferTask(task, "推送处理暂时失败," + StrUtil.maxLength(e.getMessage(), 500)); + log.warn("推送任务延后重试,taskId={}", taskId, e); + } + } + + private void expandAudience(MemberPushTaskDO task) { + List memberIds = JSONUtil.toList(task.getMemberIds(), Long.class); + LambdaQueryWrapper query = new LambdaQueryWrapper() + .gt(MemberPushDeviceDO::getId, task.getAudienceCursor()) + .le(MemberPushDeviceDO::getId, task.getSnapshotMaxId()) + .eq(MemberPushDeviceDO::getAppKey, gateway.getAppKey()) + .ne(MemberPushDeviceDO::getAppKey, "") + .eq(MemberPushDeviceDO::getProvider, "jpush") + .in("USER".equals(task.getTargetType()), MemberPushDeviceDO::getMemberId, memberIds) + .orderByAsc(MemberPushDeviceDO::getId).last("LIMIT " + BATCH_SIZE); + List devices = deviceMapper.selectList(query); + Map users = loadUsers(devices.stream().map(MemberPushDeviceDO::getMemberId) + .collect(Collectors.toSet())); + transactions.executeWithoutResult(status -> { + for (MemberPushDeviceDO device : devices) { + MemberUserDO user = users.get(device.getMemberId()); + MemberPushRecordDO record = newRecord(task, device.getMemberId(), user); + record.setDeviceId(device.getId()); + record.setPlatform(device.getPlatform()); + record.setRegistrationId(device.getRegistrationId()); + if (!isEnabled(user)) { + record.setStatus("SKIPPED"); + record.setErrorCode("MEMBER_UNAVAILABLE"); + record.setErrorMessage("接收会员不存在或已停用"); + } + recordMapper.insert(record); + } + if (devices.size() < BATCH_SIZE) { + task.setAudienceComplete(true); + for (Long memberId : memberIds) { + if (recordMapper.selectCount(new LambdaQueryWrapper() + .eq(MemberPushRecordDO::getTaskId, task.getId()) + .eq(MemberPushRecordDO::getMemberId, memberId)) == 0) { + MemberPushRecordDO record = newRecord(task, memberId, userMapper.selectById(memberId)); + record.setStatus("SKIPPED"); + record.setErrorCode("NO_DEVICE"); + record.setErrorMessage("会员未绑定极光推送设备"); + recordMapper.insert(record); + } + } + } + if (!devices.isEmpty()) { + task.setAudienceCursor(devices.get(devices.size() - 1).getId()); + } + task.setStatus("RUNNING"); + task.setNextRetryTime(LocalDateTime.now()); + taskMapper.updateById(task); + }); + refreshSummary(task); + } + + private MemberPushRecordDO newRecord(MemberPushTaskDO task, Long memberId, MemberUserDO user) { + MemberPushRecordDO record = new MemberPushRecordDO(); + record.setTenantId(task.getTenantId()); + record.setTaskId(task.getId()); + record.setMemberId(memberId); + record.setNickname(user == null ? "会员已删除" : user.getNickname()); + record.setUserCode(user == null ? "" : user.getUserCode()); + record.setRegistrationId(""); + record.setStatus("WAITING"); + record.setAttemptCount(0); + record.setNextRetryTime(LocalDateTime.now()); + return record; + } + + private List filterEligible(MemberPushTaskDO task, List records) { + ChatPrivatePushContextRespVO context = null; + if ("AUTO".equals(task.getSource())) { + context = chatApi.privatePushContext(task.getMessageId()); + if (!Boolean.TRUE.equals(context.getEligible())) { + for (MemberPushRecordDO record : records) { + skip(record, StrUtil.blankToDefault(context.getSkipReason(), "MESSAGE_UNAVAILABLE"), + "消息当前不满足推送条件"); + } + return Collections.emptyList(); + } + if (!Objects.equals(context.getMessageId(), task.getMessageId()) + || !Objects.equals(context.getRecvId(), task.getReceiverId()) + || !Objects.equals(context.getSendId(), task.getSenderId())) { + throw new IllegalStateException("IM 推送上下文消息身份不匹配"); + } + } + List deviceIds = records.stream().map(MemberPushRecordDO::getDeviceId) + .filter(Objects::nonNull).collect(Collectors.toList()); + Map devices = deviceIds.isEmpty() ? Collections.emptyMap() + : deviceMapper.selectList(new LambdaQueryWrapper() + .in(MemberPushDeviceDO::getId, deviceIds) + .eq(MemberPushDeviceDO::getAppKey, gateway.getAppKey()) + .ne(MemberPushDeviceDO::getAppKey, "")).stream().collect(Collectors.toMap( + MemberPushDeviceDO::getId, Function.identity())); + Map users = loadUsers(records.stream().map(MemberPushRecordDO::getMemberId) + .collect(Collectors.toSet())); + List eligible = new ArrayList<>(); + for (MemberPushRecordDO record : records) { + MemberPushDeviceDO device = devices.get(record.getDeviceId()); + if (device == null || !Objects.equals(device.getMemberId(), record.getMemberId()) + || !Objects.equals(device.getRegistrationId(), record.getRegistrationId()) + || !"jpush".equalsIgnoreCase(device.getProvider()) || StrUtil.isBlank(device.getRegistrationId())) { + skip(record, "DEVICE_UNBOUND", "设备已解绑、已切换会员或推送应用"); + } else if (!isEnabled(users.get(record.getMemberId()))) { + skip(record, "MEMBER_UNAVAILABLE", "接收会员不存在或已停用"); + } else if (!deferForeground(record, context)) { + eligible.add(record); + } + } + return eligible; + } + + boolean deferForeground(MemberPushRecordDO record, ChatPrivatePushContextRespVO context) { + if (context == null || context.getForegroundDevices() == null) { + return false; + } + ChatPrivatePushContextRespVO.ForegroundDevice foreground = context.getForegroundDevices().stream() + .filter(device -> Objects.equals(record.getRegistrationId(), device.getRegistrationId())) + .findFirst().orElse(null); + if (foreground == null) { + return false; + } + if (StrUtil.isBlank(foreground.getSessionToken()) || foreground.getHeartbeatVersion() == null) { + throw new IllegalStateException("IM 前台状态缺少连接版本"); + } + if (Objects.equals(record.getForegroundSession(), foreground.getSessionToken()) + && record.getForegroundVersion() != null + && foreground.getHeartbeatVersion() > record.getForegroundVersion()) { + skip(record, "FOREGROUND", "设备已确认在前台正常聊天"); + return true; + } + if (!Objects.equals(record.getForegroundSession(), foreground.getSessionToken())) { + record.setForegroundSession(foreground.getSessionToken()); + record.setForegroundVersion(foreground.getHeartbeatVersion()); + } + record.setNextRetryTime(LocalDateTime.now().plusSeconds(1)); + recordMapper.updateById(record); + return true; + } + + private void sendNewBatch(MemberPushTaskDO task, List records) { + String cid = gateway.allocateCid(); + String payload = gateway.payload(task, records, cid); + transactions.executeWithoutResult(status -> { + for (MemberPushRecordDO record : records) { + record.setCid(cid); + record.setStatus("SENDING"); + record.setAttemptCount(record.getAttemptCount() + 1); + record.setNextRetryTime(LocalDateTime.now()); + int claimed = recordMapper.update(null, new LambdaUpdateWrapper() + .eq(MemberPushRecordDO::getId, record.getId()) + .eq(MemberPushRecordDO::getTaskId, task.getId()) + .eq(MemberPushRecordDO::getStatus, "WAITING") + .isNull(MemberPushRecordDO::getCid) + .set(MemberPushRecordDO::getCid, cid) + .set(MemberPushRecordDO::getStatus, "SENDING") + .setSql("attempt_count = attempt_count + 1") + .set(MemberPushRecordDO::getNextRetryTime, record.getNextRetryTime())); + if (claimed != 1) { + throw new IllegalStateException("推送设备记录已由其他任务领取"); + } + } + MemberPushRecordDO leader = records.get(0); + leader.setBatchPayload(payload); + recordMapper.updateById(leader); + }); + submitBatch(task, records, cid, payload); + } + + private void resumeBatch(MemberPushTaskDO task, String cid) { + List records = recordMapper.selectList(new LambdaQueryWrapper() + .eq(MemberPushRecordDO::getTaskId, task.getId()).eq(MemberPushRecordDO::getCid, cid) + .eq(MemberPushRecordDO::getStatus, "SENDING").orderByAsc(MemberPushRecordDO::getId)); + if (records.isEmpty()) { + return; + } + List deviceIds = records.stream().map(MemberPushRecordDO::getDeviceId).collect(Collectors.toList()); + Map devices = deviceMapper.selectList(new LambdaQueryWrapper() + .in(MemberPushDeviceDO::getId, deviceIds) + .eq(MemberPushDeviceDO::getAppKey, gateway.getAppKey()) + .ne(MemberPushDeviceDO::getAppKey, "")).stream() + .collect(Collectors.toMap(MemberPushDeviceDO::getId, Function.identity())); + Map users = loadUsers(records.stream().map(MemberPushRecordDO::getMemberId) + .collect(Collectors.toSet())); + boolean changed = records.stream().anyMatch(record -> { + MemberPushDeviceDO device = devices.get(record.getDeviceId()); + return device == null || !Objects.equals(device.getMemberId(), record.getMemberId()) + || !Objects.equals(device.getRegistrationId(), record.getRegistrationId()) + || !"jpush".equalsIgnoreCase(device.getProvider()) || !isEnabled(users.get(record.getMemberId())); + }); + if (changed) { + cancelUnconfirmedBatch(task, cid, "TARGET_CHANGED_UNCONFIRMED", + "设备绑定、推送应用或会员状态已变化,原批次提交结果未确认,已结束重试"); + return; + } + if ("AUTO".equals(task.getSource())) { + ChatPrivatePushContextRespVO context = chatApi.privatePushContext(task.getMessageId()); + if (!Boolean.TRUE.equals(context.getEligible())) { + cancelUnconfirmedBatch(task, cid, "POLICY_CHANGED_UNCONFIRMED", + "消息当前不满足推送条件,原提交结果未确认," + context.getSkipReason()); + return; + } + if (!Objects.equals(context.getMessageId(), task.getMessageId()) + || !Objects.equals(context.getSendId(), task.getSenderId()) + || !Objects.equals(context.getRecvId(), task.getReceiverId())) { + throw new IllegalStateException("IM 推送恢复上下文消息身份不匹配"); + } + Set foreground = context.getForegroundDevices() == null ? Collections.emptySet() + : context.getForegroundDevices().stream() + .map(ChatPrivatePushContextRespVO.ForegroundDevice::getRegistrationId).collect(Collectors.toSet()); + if (records.stream().anyMatch(record -> foreground.contains(record.getRegistrationId()))) { + recordMapper.update(null, batch(task.getId(), cid) + .set(MemberPushRecordDO::getNextRetryTime, LocalDateTime.now().plusSeconds(1))); + return; + } + } + String payload = records.stream().map(MemberPushRecordDO::getBatchPayload) + .filter(StrUtil::isNotBlank).findFirst() + .orElseThrow(() -> new IllegalStateException("推送恢复缺少原始批次内容")); + for (MemberPushRecordDO record : records) { + record.setAttemptCount(record.getAttemptCount() + 1); + recordMapper.updateById(record); + } + submitBatch(task, records, cid, payload); + } + + private void cancelUnconfirmedBatch(MemberPushTaskDO task, String cid, String code, String message) { + // 固定 CID 的批次受众保持完整,归属变化后保留原始结果待查记录。 + recordMapper.update(null, batch(task.getId(), cid) + .set(MemberPushRecordDO::getStatus, "FAILED") + .set(MemberPushRecordDO::getErrorCode, code).set(MemberPushRecordDO::getErrorMessage, message)); + task.setLastError(message); + } + + private void submitBatch(MemberPushTaskDO task, List records, String cid, String payload) { + try { + String msgId = gateway.send(payload); + recordMapper.update(null, batch(task.getId(), cid) + .set(MemberPushRecordDO::getStatus, "SUBMITTED") + .set(MemberPushRecordDO::getJpushMsgId, msgId) + .set(MemberPushRecordDO::getSendTime, LocalDateTime.now()) + .set(MemberPushRecordDO::getErrorCode, null).set(MemberPushRecordDO::getErrorMessage, null)); + task.setAttemptCount(0); + } catch (JPushGateway.PushFailure e) { + int attempt = records.stream().mapToInt(MemberPushRecordDO::getAttemptCount).max().orElse(1); + LambdaUpdateWrapper update = batch(task.getId(), cid) + .set(MemberPushRecordDO::getErrorCode, e.getCode()) + .set(MemberPushRecordDO::getErrorMessage, e.getMessage()) + .set(MemberPushRecordDO::getNextRetryTime, retryAt(attempt)); + if (!e.isRetryable()) { + update.set(MemberPushRecordDO::getStatus, "FAILED"); + } else if (!e.isUncertain()) { + // 极光明确拒绝的批次重新检查当前受众,再分配新 CID。 + update.set(MemberPushRecordDO::getStatus, "WAITING") + .set(MemberPushRecordDO::getCid, null).set(MemberPushRecordDO::getBatchPayload, null); + } + recordMapper.update(null, update); + task.setLastError(e.getMessage()); + } + } + + private LambdaUpdateWrapper batch(Long taskId, String cid) { + return new LambdaUpdateWrapper() + .eq(MemberPushRecordDO::getTaskId, taskId).eq(MemberPushRecordDO::getCid, cid) + .eq(MemberPushRecordDO::getStatus, "SENDING"); + } + + private void skip(MemberPushRecordDO record, String code, String message) { + record.setStatus("SKIPPED"); + record.setErrorCode(code); + record.setErrorMessage(message); + recordMapper.updateById(record); + } + + private void refreshSummary(MemberPushTaskDO task) { + Map counts = recordMapper.summarize(task.getId()); + task.setTotalMembers(count(counts, "total_members")); + task.setTotalDevices(count(counts, "total_devices")); + task.setSubmittedCount(count(counts, "submitted_count")); + task.setFailedCount(count(counts, "failed_count")); + task.setSkippedCount(count(counts, "skipped_count")); + task.setProcessedCount(task.getSubmittedCount() + task.getFailedCount() + task.getSkippedCount()); + if (Boolean.TRUE.equals(task.getAudienceComplete()) && count(counts, "pending_count") == 0) { + String state = "COMPLETED"; + if (task.getFailedCount() > 0) { + state = task.getSubmittedCount() > 0 ? "PARTIAL_FAILED" : "FAILED"; + } else if (task.getSubmittedCount() == 0) { + state = "SKIPPED"; + } + if (task.getFailedCount() == 0) { + task.setLastError(""); + } + task.setStatus(state); + task.setFinishTime(LocalDateTime.now()); + } else { + task.setStatus("RUNNING"); + MemberPushRecordDO next = recordMapper.selectOne(new LambdaQueryWrapper() + .eq(MemberPushRecordDO::getTaskId, task.getId()).in(MemberPushRecordDO::getStatus, "WAITING", "SENDING") + .orderByAsc(MemberPushRecordDO::getNextRetryTime).last("LIMIT 1")); + LocalDateTime now = LocalDateTime.now(); + task.setNextRetryTime(next == null || next.getNextRetryTime().isBefore(now) + ? now : next.getNextRetryTime()); + } + taskMapper.updateById(task); + } + + private int count(Map counts, String name) { + Object value = counts.get(name); + return value == null ? 0 : ((Number) value).intValue(); + } + + private void deferTask(MemberPushTaskDO task, String error) { + task.setAttemptCount(task.getAttemptCount() + 1); + task.setLastError(StrUtil.maxLength(error, 1000)); + task.setNextRetryTime(retryAt(task.getAttemptCount())); + // 事务回滚后内存中的受众游标可能已经变化,只更新重试信息。 + taskMapper.update(null, new LambdaUpdateWrapper() + .eq(MemberPushTaskDO::getId, task.getId()) + .set(MemberPushTaskDO::getAttemptCount, task.getAttemptCount()) + .set(MemberPushTaskDO::getLastError, task.getLastError()) + .set(MemberPushTaskDO::getNextRetryTime, task.getNextRetryTime())); + } + + private void failTask(MemberPushTaskDO task, String code, String error) { + transactions.executeWithoutResult(status -> { + recordMapper.update(null, new LambdaUpdateWrapper() + .eq(MemberPushRecordDO::getTaskId, task.getId()).in(MemberPushRecordDO::getStatus, "WAITING", "SENDING") + .set(MemberPushRecordDO::getStatus, "FAILED") + .set(MemberPushRecordDO::getErrorCode, code).set(MemberPushRecordDO::getErrorMessage, error)); + task.setAudienceComplete(true); + refreshSummary(task); + task.setStatus(task.getSubmittedCount() > 0 ? "PARTIAL_FAILED" : "FAILED"); + task.setLastError(error); + task.setFinishTime(LocalDateTime.now()); + taskMapper.updateById(task); + }); + } + + private Map loadUsers(Collection ids) { + if (ids.isEmpty()) { + return Collections.emptyMap(); + } + return userMapper.selectBatchIds(ids).stream().collect(Collectors.toMap(MemberUserDO::getId, Function.identity())); + } + + private boolean isEnabled(MemberUserDO user) { + return user != null && Integer.valueOf(0).equals(user.getStatus()); + } + + private LocalDateTime retryAt(int attempts) { + return LocalDateTime.now().plusSeconds(RETRY_SECONDS[Math.min(Math.max(attempts - 1, 0), RETRY_SECONDS.length - 1)]); + } + + private static String alert(Integer type, String content) { + switch (type) { + case 0: return StrUtil.maxLength(StrUtil.blankToDefault(content, "您收到一条新消息"), 100); + case 1: return "[图片]"; + case 2: return "[文件]"; + case 3: return "[语音]"; + case 4: return "[视频]"; + default: return "您收到一条新消息"; + } + } + + private ServiceException badRequest(String message) { + return new ServiceException(400, message); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/ChatApiImplPushContextTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/ChatApiImplPushContextTest.java new file mode 100644 index 0000000..17e58f4 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/ChatApiImplPushContextTest.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.member.client.service; + +import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivatePushContextRespVO; +import cn.iocoder.yudao.module.member.framework.web.config.ChatProperties; +import com.sun.net.httpserver.HttpServer; +import org.apache.commons.codec.digest.DigestUtils; +import org.junit.jupiter.api.*; +import org.springframework.test.util.ReflectionTestUtils; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; +import static org.junit.jupiter.api.Assertions.*; + +class ChatApiImplPushContextTest { + private HttpServer server; + private ChatApiImpl api; + private final AtomicReference requestBody = new AtomicReference<>(); + private final AtomicReference requestSign = new AtomicReference<>(); + private final AtomicReference expectedSign = new AtomicReference<>(); + private final AtomicReference response = new AtomicReference<>(); + + @BeforeEach + void setUp() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/third/privateMsg/pushContext", exchange -> { + java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream(); + byte[] chunk = new byte[1024]; + int count; + while ((count = exchange.getRequestBody().read(chunk)) != -1) { + buffer.write(chunk, 0, count); + } + String body = new String(buffer.toByteArray(), StandardCharsets.UTF_8); + requestBody.set(body); + requestSign.set(exchange.getRequestHeaders().getFirst("SIGN")); + expectedSign.set(DigestUtils.md5Hex("test-app" + "test-secret" + "10" + + exchange.getRequestHeaders().getFirst("TIMESTAMP") + body)); + byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + ChatProperties properties = new ChatProperties(); + properties.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort()); + properties.setAppKey("test-app"); + properties.setAppSecret("test-secret"); + api = new ChatApiImpl(); + ReflectionTestUtils.setField(api, "chatProperties", properties); + } + + @AfterEach + void stop() { + if (server != null) server.stop(0); + } + + @Test + void signsRequestAndParsesActualImResponseEnvelope() { + response.set("{\"code\":200,\"message\":\"成功\",\"data\":{\"messageId\":7,\"sendId\":11," + + "\"recvId\":12,\"eligible\":true,\"foregroundDevices\":[{\"registrationId\":\"rid\"," + + "\"sessionToken\":\"session\",\"heartbeatVersion\":2,\"leaseExpiresAt\":1700000000000}]}}"); + ChatPrivatePushContextRespVO context = api.privatePushContext(7L); + assertEquals("{\"messageId\":7}", requestBody.get()); + assertEquals(expectedSign.get(), requestSign.get()); + assertTrue(context.getEligible()); + assertEquals(12L, context.getRecvId()); + assertEquals(2L, context.getForegroundDevices().get(0).getHeartbeatVersion()); + response.set("{\"code\":200,\"data\":{\"messageId\":7,\"eligible\":false,\"skipReason\":\"DND\"}}"); + assertEquals("DND", api.privatePushContext(7L).getSkipReason()); + } + + @Test + void unavailableOrIncompleteContextFailsForRetry() { + for (String invalid : new String[] {"{\"code\":500,\"message\":\"test failure\"}", + "{\"code\":200,\"data\":{}}", "unavailable"}) { + response.set(invalid); + assertThrows(IllegalStateException.class, () -> api.privatePushContext(7L)); + } + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/push/MemberPushControllerSecurityTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/push/MemberPushControllerSecurityTest.java new file mode 100644 index 0000000..02676ba --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/push/MemberPushControllerSecurityTest.java @@ -0,0 +1,95 @@ +package cn.iocoder.yudao.module.member.controller.admin.push; + +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.module.member.controller.admin.push.vo.*; +import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserPageReqVO; +import cn.iocoder.yudao.module.member.service.push.MemberPushTaskService; +import org.junit.jupiter.api.*; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.core.context.SecurityContextHolder; +import javax.validation.Validation; +import javax.validation.ValidatorFactory; +import java.util.Collections; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class MemberPushControllerSecurityTest { + @AfterEach + void clearSecurity() { + SecurityContextHolder.clearContext(); + } + + @Test + void queryAndSendPermissionsAreIndependentAndOperatorComesFromLogin() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.getBeanFactory().registerSingleton("service", mock(MemberPushTaskService.class)); + context.register(Config.class); + context.refresh(); + MemberPushController controller = context.getBean(MemberPushController.class); + MemberPushTaskService service = context.getBean(MemberPushTaskService.class); + Permission permission = context.getBean(Permission.class); + LoginUser login = new LoginUser(); + login.setId(42L); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(login, null, Collections.emptyList())); + MemberPushSendReqVO send = new MemberPushSendReqVO(); + assertThrows(AccessDeniedException.class, () -> controller.send(send)); + assertThrows(AccessDeniedException.class, () -> controller.page(new MemberPushPageReqVO())); + assertThrows(AccessDeniedException.class, () -> controller.get(1L)); + assertThrows(AccessDeniedException.class, () -> controller.records(new MemberPushRecordPageReqVO())); + assertThrows(AccessDeniedException.class, () -> controller.users(new MemberUserPageReqVO())); + verifyNoInteractions(service); + permission.send = true; + when(service.createManual(send, 42L)).thenReturn(7L); + assertEquals(7L, controller.send(send).getData().get("taskId")); + controller.users(new MemberUserPageReqVO()); + assertThrows(AccessDeniedException.class, () -> controller.get(7L)); + verify(service).createManual(same(send), eq(42L)); + permission.send = false; + permission.query = true; + controller.get(7L); + assertThrows(AccessDeniedException.class, () -> controller.send(send)); + } + } + + @Test + void validatesAudienceTitleAndBodyLimits() { + try (ValidatorFactory validators = Validation.buildDefaultValidatorFactory()) { + MemberPushSendReqVO req = new MemberPushSendReqVO(); + req.setRequestId("test-request"); + req.setTargetType("ALL"); + req.setTitle("通知"); + req.setContent("正文"); + assertTrue(validators.getValidator().validate(req).isEmpty()); + req.setTitle(String.join("", Collections.nCopies(41, "字"))); + req.setContent(String.join("", Collections.nCopies(101, "字"))); + req.setMemberIds(Collections.singletonList(-1L)); + req.setTargetType("UNKNOWN"); + assertEquals(4, validators.getValidator().validate(req).size()); + } + } + + public static class Permission { + boolean query; + boolean send; + public boolean hasPermission(String name) { + return "member:push:query".equals(name) ? query : send; + } + public boolean hasAnyPermissions(String... names) { + for (String name : names) { + if (hasPermission(name)) return true; + } + return false; + } + } + + @Configuration + @EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true) + static class Config { + @Bean public MemberPushController controller() { return new MemberPushController(); } + @Bean(name = "ss") public Permission permission() { return new Permission(); } + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/thirdcallback/ImPushCallbackControllerTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/thirdcallback/ImPushCallbackControllerTest.java new file mode 100644 index 0000000..4788c08 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/thirdcallback/ImPushCallbackControllerTest.java @@ -0,0 +1,96 @@ +package cn.iocoder.yudao.module.member.controller.admin.thirdcallback; + +import cn.iocoder.yudao.module.member.service.push.ImPrivatePushEvent; +import cn.iocoder.yudao.module.member.service.push.MemberPushTaskService; +import org.apache.commons.codec.binary.Hex; +import org.junit.jupiter.api.*; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import javax.validation.Validation; +import javax.validation.ValidatorFactory; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class ImPushCallbackControllerTest { + private final ImPushCallbackController controller = new ImPushCallbackController(); + private final MemberPushTaskService service = mock(MemberPushTaskService.class); + private ValidatorFactory validators; + private final String body = "{\"eventId\":\"private:7\",\"messageId\":7,\"sendId\":11,\"recvId\":12," + + "\"type\":0,\"content\":\"收到消息😊\",\"sendTime\":1700000000000}"; + + @BeforeEach + void setUp() { + validators = Validation.buildDefaultValidatorFactory(); + ReflectionTestUtils.setField(controller, "secret", "isolated-test-secret"); + ReflectionTestUtils.setField(controller, "service", service); + ReflectionTestUtils.setField(controller, "validator", validators.getValidator()); + } + + @AfterEach + void close() { + validators.close(); + } + + @Test + void acceptsSignedRawUtf8BodyAndReturnsCommittedTask() throws Exception { + String timestamp = String.valueOf(Instant.now().getEpochSecond()); + when(service.acceptPrivate(any())).thenReturn(71L); + assertEquals(71L, controller.receive(body, timestamp, sign(timestamp, body)).getData().get("taskId")); + verify(service).acceptPrivate(argThat(event -> event.getMessageId() == 7L + && event.getRecvId() == 12L && "收到消息😊".equals(event.getContent()))); + } + + @Test + void rejectsTamperingExpiredFutureAndUnconfiguredSecret() throws Exception { + String timestamp = String.valueOf(Instant.now().getEpochSecond()); + String signature = sign(timestamp, body); + assertEquals(401, assertThrows(ResponseStatusException.class, + () -> controller.receive(body + " ", timestamp, signature)).getRawStatusCode()); + for (long offset : new long[] {-301, 301}) { + String invalidTime = String.valueOf(Instant.now().getEpochSecond() + offset); + String invalidSignature = sign(invalidTime, body); + assertEquals(401, assertThrows(ResponseStatusException.class, + () -> controller.receive(body, invalidTime, invalidSignature)).getRawStatusCode()); + } + ReflectionTestUtils.setField(controller, "secret", ""); + assertEquals(401, assertThrows(ResponseStatusException.class, + () -> controller.receive(body, timestamp, signature)).getRawStatusCode()); + verifyNoInteractions(service); + } + + @Test + void rejectsSignedInvalidMessageAndMismatchedEventIdentity() throws Exception { + String timestamp = String.valueOf(Instant.now().getEpochSecond()); + for (String invalid : new String[] {"{}", body.replace("private:7", "private:8"), + body.replace("\"type\":0", "\"type\":20"), body.replace("\"recvId\":12", "\"recvId\":-200")}) { + String signature = sign(timestamp, invalid); + assertEquals(400, assertThrows(ResponseStatusException.class, + () -> controller.receive(invalid, timestamp, signature)).getRawStatusCode()); + } + verifyNoInteractions(service); + } + + @Test + void invalidSignatureReturnsHttpUnauthorized() throws Exception { + MockMvcBuilders.standaloneSetup(controller).build().perform(post("/third-callback/im-private-message") + .contentType("application/json").content(body) + .header("X-IM-TIMESTAMP", String.valueOf(Instant.now().getEpochSecond())) + .header("X-IM-SIGNATURE", String.join("", java.util.Collections.nCopies(64, "0")))) + .andExpect(status().isUnauthorized()); + verifyNoInteractions(service); + } + + private String sign(String timestamp, String text) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec("isolated-test-secret".getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return Hex.encodeHexString(mac.doFinal((timestamp + "\n" + text).getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionPackageParserTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionPackageParserTest.java new file mode 100644 index 0000000..ae38c96 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionPackageParserTest.java @@ -0,0 +1,301 @@ +package cn.iocoder.yudao.module.member.service.appversion; + +import cn.iocoder.yudao.module.member.dal.dataobject.appversion.AppVersionPackageDO; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.MessageDigest; +import java.util.*; +import java.util.zip.*; +import static org.junit.jupiter.api.Assertions.*; + +class AppVersionPackageParserTest { + @TempDir Path directory; + private final AppVersionPackageParser parser = new AppVersionPackageParser(); + // aapt 编译的最小测试清单。package=com.friendship.chat.test,versionCode=120,versionName=1.2.0。 + // 在源码内保留这个小 fixture,使测试可以脱离本机 Android SDK 运行。 + private static final String ANDROID_MANIFEST = + "AwAIAIAEAAABABwAwAIAABQAAAAAAAAAAAAAAGwAAAAAAAAAAAAAABoAAAA0AAAAWgAAAJAAAACuAAAAvAAAAM4AAAAmAQAAKgEA" + + "ADwBAABwAQAApAEAALgBAADsAQAA+gEAAAICAAAKAgAAHgIAADgCAAALAHYAZQByAHMAaQBvAG4AQwBvAGQAZQAAAAsAdgBlAHIA" + + "cwBpAG8AbgBOAGEAbQBlAAAAEQBjAG8AbQBwAGkAbABlAFMAZABrAFYAZQByAHMAaQBvAG4AAAAZAGMAbwBtAHAAaQBsAGUAUwBk" + + "AGsAVgBlAHIAcwBpAG8AbgBDAG8AZABlAG4AYQBtAGUAAAANAG0AaQBuAFMAZABrAFYAZQByAHMAaQBvAG4AAAAFAGwAYQBiAGUA" + + "bAAAAAcAYQBuAGQAcgBvAGkAZAAAACoAaAB0AHQAcAA6AC8ALwBzAGMAaABlAG0AYQBzAC4AYQBuAGQAcgBvAGkAZAAuAGMAbwBt" + + "AC8AYQBwAGsALwByAGUAcwAvAGEAbgBkAHIAbwBpAGQAAAAAAAAABwBwAGEAYwBrAGEAZwBlAAAAGABwAGwAYQB0AGYAbwByAG0A" + + "QgB1AGkAbABkAFYAZQByAHMAaQBvAG4AQwBvAGQAZQAAABgAcABsAGEAdABmAG8AcgBtAEIAdQBpAGwAZABWAGUAcgBzAGkAbwBu" + + "AE4AYQBtAGUAAAAIAG0AYQBuAGkAZgBlAHMAdAAAABgAYwBvAG0ALgBmAHIAaQBlAG4AZABzAGgAaQBwAC4AYwBoAGEAdAAuAHQA" + + "ZQBzAHQAAAAFADEALgAyAC4AMAAAAAIAMQA1AAAAAgAzADUAAAAIAHUAcwBlAHMALQBzAGQAawAAAAsAYQBwAHAAbABpAGMAYQB0" + + "AGkAbwBuAAAADABWAGUAcgBzAGkAbwBuACAAVABlAHMAdAAAAIABCAAgAAAAGwIBARwCAQFyBQEBcwUBAQwCAQEBAAEBAAEQABgA" + + "AAABAAAA/////wYAAAAHAAAAAgEQALAAAAABAAAA//////////8MAAAAFAAUAAcAAAAAAAAABwAAAAAAAAD/////CAAAEHgAAAAH" + + "AAAAAQAAAA4AAAAIAAADDgAAAAcAAAACAAAA/////wgAABAjAAAABwAAAAMAAAAPAAAACAAAAw8AAAD/////CQAAAA0AAAAIAAAD" + + "DQAAAP////8KAAAAEAAAAAgAABAjAAAA/////wsAAAAPAAAACAAAEA8AAAACARAAOAAAAAEAAAD//////////xEAAAAUABQAAQAA" + + "AAAAAAAHAAAABAAAAP////8IAAAQFQAAAAMBEAAYAAAAAQAAAP//////////EQAAAAIBEAA4AAAAAQAAAP//////////EgAAABQA" + + "FAABAAAAAAAAAAcAAAAFAAAAEwAAAAgAAAMTAAAAAwEQABgAAAABAAAA//////////8SAAAAAwEQABgAAAABAAAA//////////8M" + + "AAAAAQEQABgAAAABAAAA/////wYAAAAHAAAA"; + + + // 最小合法 resources.arsc,资源 0x7f010000 对应 UTF-8 字符串 1.2.0。 + // 该 fixture 已经与原 APK 解析库交叉验证,无需测试环境安装 Android SDK。 + private static final String VERSION_NAME_RESOURCES = + "AgAMACwCAAABAAAAAQAcACgAAAABAAAAAAAAAAABAAAgAAAAAAAAAAAAAAAFBTEuMi4wAAACIAH4AQAAfwAAAGMAbwBtAC4AZgByAGkAZQBuAGQA" + + "cwBoAGkAcAAuAGMAaABhAHQALgB0AGUAcwB0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAQAAAQAAAEwBAAABAAAA" + + "AAAAAAEAHAAsAAAAAQAAAAAAAAAAAQAAIAAAAAAAAAAAAAAABgZzdHJpbmcAAAAAAQAcADAAAAABAAAAAAAAAAABAAAgAAAAAAAAAAAAAAAMDHZl" + + "cnNpb25fbmFtZQAAAgIQABQAAAABAAAAAQAAAAAAAAABAlQAaAAAAAEAAAABAAAAWAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAIAAADAAAAAA=="; + + @Test + void parsesCompiledWgtAndComputesBothDigests() throws Exception { + File file = archive(Collections.singletonMap("manifest.json", manifest("__UNI__0A84F60", "135"))); + AppVersionPackageDO result = parser.parse(file, "update.wgt", "ios"); + assertEquals("ios", result.getPlatform()); + assertEquals("wgt", result.getPackageType()); + assertEquals(135, result.getResourceVersionCode()); + assertEquals(135, result.getVersionCode()); + assertEquals("1.3.5", result.getVersionName()); + assertNull(result.getNativeVersionCode()); + assertEquals(file.length(), result.getFileSize()); + assertEquals(digest("SHA-256", file), result.getSha256()); + assertEquals(digest("MD5", file), result.getMd5()); + } + + @Test + void parsesActualBinaryAndroidManifestSeparatelyFromResources() throws Exception { + Map entries = new LinkedHashMap<>(); + entries.put("AndroidManifest.xml", Base64.getDecoder().decode(ANDROID_MANIFEST)); + entries.put("assets/apps/__UNI__0A84F60/www/manifest.json", manifest("__UNI__0A84F60", "135")); + AppVersionPackageDO result = parser.parse(archive(entries), "full.apk", "android"); + assertEquals("apk", result.getPackageType()); + assertEquals("com.friendship.chat.test", result.getPackageName()); + assertEquals(120, result.getNativeVersionCode()); + assertEquals(120, result.getVersionCode()); + assertEquals(135, result.getResourceVersionCode()); + assertEquals("1.2.0", result.getVersionName()); + } + + @Test + void recognizesHarmonyWgtAndStripsUploadedDirectories() throws Exception { + File file = archive(Collections.singletonMap("manifest.json", manifest("__UNI__0A84F60", "135"))); + assertEquals("harmony", parser.parse(file, "C:\\build\\update.wgt", "harmony").getPlatform()); + assertEquals("update.wgt", parser.parse(file, "C:\\build\\update.wgt", "android").getFileName()); + } + + @Test + void rejectsAnotherAppAndInvalidVersions() throws Exception { + assertThrows(IllegalArgumentException.class, () -> parser.parse( + archive(Collections.singletonMap("manifest.json", manifest("__UNI__OTHER", "135"))), "update.wgt", "android")); + for (String value : Arrays.asList("0", "-1", "2147483648", "1.2")) { + assertThrows(IllegalArgumentException.class, () -> parser.parse( + archive(Collections.singletonMap("manifest.json", manifest("__UNI__0A84F60", value))), "update.wgt", "android")); + } + } + + @Test + void rejectsUnsupportedPlatformAndIosApk() throws Exception { + File file = archive(Collections.singletonMap("manifest.json", manifest("__UNI__0A84F60", "135"))); + assertThrows(IllegalArgumentException.class, () -> parser.parse(file, "update.apk", "ios")); + assertThrows(IllegalArgumentException.class, () -> parser.parse(file, "update.wgt", "h5")); + assertThrows(IllegalArgumentException.class, () -> parser.parse(file, "update.zip", "android")); + } + + @Test + void requiresCompiledManifestAndBoundsInflatedManifest() throws Exception { + assertThrows(IllegalArgumentException.class, () -> parser.parse(archive(Collections.singletonMap("readme", new byte[]{1})), "update.wgt", "android")); + byte[] oversized = new byte[1024 * 1024 + 1]; + Arrays.fill(oversized, (byte) ' '); + assertThrows(IllegalArgumentException.class, () -> parser.parse(archive(Collections.singletonMap("manifest.json", oversized)), "update.wgt", "android")); + } + + + @Test + void resolvesNativeVersionNameFromReferencedResource() throws Exception { + AppVersionPackageDO result = parser.parse(apk(referencedVersionNameManifest(), nativeResources()), "referenced.apk", "android"); + assertEquals("com.friendship.chat.test", result.getPackageName()); + assertEquals(120, result.getNativeVersionCode()); + assertEquals(135, result.getResourceVersionCode()); + assertEquals("1.2.0", result.getVersionName()); + } + + @Test + void avoidsReadingUnrelatedResourceTableForLiteralVersionName() throws Exception { + for (byte[] resources : Arrays.asList(resourcesWithInflatedCount(), resourcesWithInflatedStringLength())) { + AppVersionPackageDO result = parser.parse(apk(binaryManifest(), resources), "literal.apk", "android"); + assertEquals("1.2.0", result.getVersionName()); + assertEquals(120, result.getNativeVersionCode()); + } + } + + @Test + void rejectsManifestStringCountLargerThanActualOffsetTable() throws Exception { + byte[] androidManifest = binaryManifest(); + // AXML 根头占 8 字节,紧随其后的字符串池中 stringCount 位于偏移 8。 + littleEndian(androidManifest).putInt(16, 100_000_000); + assertThrows(IllegalArgumentException.class, () -> parser.parse(apk(androidManifest, null), "bad-count.apk", "android")); + } + + @Test + void rejectsManifestStringLengthBeforeAllocatingFromDeclaredSize() throws Exception { + byte[] androidManifest = binaryManifest(); + // 原 fixture 的字符串索引 14 是原生版本名,修改为 UTF-16 双字长度的一亿字符。 + putInflatedUtf16Length(androidManifest, fixtureStringPosition(androidManifest, 14)); + assertThrows(IllegalArgumentException.class, () -> parser.parse(apk(androidManifest, null), "bad-length.apk", "android")); + } + + @Test + void rejectsReferencedResourceCountLargerThanActualOffsetTable() throws Exception { + File file = apk(referencedVersionNameManifest(), resourcesWithInflatedCount()); + assertThrows(IllegalArgumentException.class, () -> parser.parse(file, "bad-resource-count.apk", "android")); + } + + @Test + void rejectsReferencedResourceLengthBeforeAllocatingFromDeclaredSize() throws Exception { + File file = apk(referencedVersionNameManifest(), resourcesWithInflatedStringLength()); + assertThrows(IllegalArgumentException.class, () -> parser.parse(file, "bad-resource-length.apk", "android")); + } + + @Test + void recognizesAndroidNamespaceUriAfterPrefixIsRenamed() throws Exception { + byte[] androidManifest = binaryManifest(); + int position = fixtureStringPosition(androidManifest, 6) + 2; + // 字符串索引 6 是七字符前缀 android。属性仍使用索引 7 的固定 Android URI。 + byte[] renamedPrefix = "appcode".getBytes(StandardCharsets.UTF_16LE); + System.arraycopy(renamedPrefix, 0, androidManifest, position, renamedPrefix.length); + AppVersionPackageDO result = parser.parse(apk(androidManifest, null), "renamed-prefix.apk", "android"); + assertEquals(120, result.getNativeVersionCode()); + assertEquals("1.2.0", result.getVersionName()); + } + + @Test + void rejectsVersionAttributesOutsideAndroidNamespace() throws Exception { + // AAPT fixture 中索引 0/1 分别是 versionCode/versionName。 + for (int nameIndex : new int[]{0, 1}) { + byte[] androidManifest = binaryManifest(); + littleEndian(androidManifest).putInt(manifestAttribute(androidManifest, nameIndex), -1); + assertThrows(IllegalArgumentException.class, () -> parser.parse(apk(androidManifest, null), "unqualified.apk", "android")); + } + } + + @Test + void rejectsPackageAttributePlacedInAndroidNamespace() throws Exception { + byte[] androidManifest = binaryManifest(); + // package 使用无命名空间,索引 9 是属性名称,索引 7 是 Android URI。 + littleEndian(androidManifest).putInt(manifestAttribute(androidManifest, 9), 7); + assertThrows(IllegalArgumentException.class, () -> parser.parse(apk(androidManifest, null), "qualified-package.apk", "android")); + } + + /** 用内嵌 AAPT 清单和可选资源表构造完整业务入口接受的最小 APK。 */ + private File apk(byte[] androidManifest, byte[] resources) throws IOException { + Map entries = new LinkedHashMap<>(); + entries.put("AndroidManifest.xml", androidManifest); + entries.put("assets/apps/__UNI__0A84F60/www/manifest.json", manifest("__UNI__0A84F60", "135")); + if (resources != null) { + entries.put("resources.arsc", resources); + } + return archive(entries); + } + + /** 每次返回独立 fixture,变异测试不会影响其他测试。 */ + private byte[] binaryManifest() { + return Base64.getDecoder().decode(ANDROID_MANIFEST); + } + + /** 将原生版本名的编译字符串改为真实 Android 资源引用。 */ + private byte[] referencedVersionNameManifest() { + byte[] androidManifest = binaryManifest(); + int attribute = manifestAttribute(androidManifest, 1); + littleEndian(androidManifest).putInt(attribute + 8, -1); + androidManifest[attribute + 15] = 1; + littleEndian(androidManifest).putInt(attribute + 16, 0x7f010000); + return androidManifest; + } + + /** 读取固定 fixture 的首个元素属性位置,使用实际编译布局避免硬编码字节偏移。 */ + private int manifestAttribute(byte[] androidManifest, int nameIndex) { + ByteBuffer bytes = littleEndian(androidManifest); + for (int position = 8; position < androidManifest.length; position += bytes.getInt(position + 4)) { + if (Short.toUnsignedInt(bytes.getShort(position)) != 0x0102) { + continue; + } + int extension = position + Short.toUnsignedInt(bytes.getShort(position + 2)); + int attributes = extension + Short.toUnsignedInt(bytes.getShort(extension + 8)); + int attributeSize = Short.toUnsignedInt(bytes.getShort(extension + 10)); + int count = Short.toUnsignedInt(bytes.getShort(extension + 12)); + for (int index = 0; index < count; index++) { + int attribute = attributes + index * attributeSize; + if (bytes.getInt(attribute + 4) == nameIndex) { + return attribute; + } + } + } + throw new AssertionError("AAPT fixture 缺少目标属性 " + nameIndex); + } + + /** 固定 AAPT fixture 使用 UTF-16 字符串池,返回包含编码长度头的字符串位置。 */ + private int fixtureStringPosition(byte[] androidManifest, int index) { + ByteBuffer bytes = littleEndian(androidManifest); + int pool = 8; + assertEquals(1, Short.toUnsignedInt(bytes.getShort(pool))); + int offsets = pool + Short.toUnsignedInt(bytes.getShort(pool + 2)); + return pool + bytes.getInt(pool + 20) + bytes.getInt(offsets + index * 4); + } + + /** 为资源引用测试提供一份独立的合法资源表。 */ + private byte[] nativeResources() { + return Base64.getDecoder().decode(VERSION_NAME_RESOURCES); + } + + /** 声明数量远超实际偏移表,旧库会在检查数据前分配巨大数组。 */ + private byte[] resourcesWithInflatedCount() { + byte[] resources = nativeResources(); + littleEndian(resources).putInt(20, 100_000_000); + return resources; + } + + /** 仅一个字符串且索引合法,但其 UTF-16 声明长度远超实际内容。 */ + private byte[] resourcesWithInflatedStringLength() { + byte[] resources = nativeResources(); + ByteBuffer bytes = littleEndian(resources); + bytes.putInt(28, 0); + putInflatedUtf16Length(resources, 44); + return resources; + } + + /** Android UTF-16 双字长度编码 0x85f5/e100 表示一亿字符。 */ + private void putInflatedUtf16Length(byte[] bytes, int position) { + littleEndian(bytes).putShort(position, (short) 0x85f5).putShort(position + 2, (short) 0xe100); + } + + /** Android 编译清单和资源表使用 little-endian 字节序。 */ + private ByteBuffer littleEndian(byte[] bytes) { + return ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + } + + private byte[] manifest(String appId, String code) { + return ("{\"id\":\"" + appId + "\",\"version\":{\"code\":\"" + code + "\",\"name\":\"1.3.5\"}}").getBytes(StandardCharsets.UTF_8); + } + + private File archive(Map entries) throws IOException { + Path path = Files.createTempFile(directory, "package-", ".zip"); + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(path))) { + for (Map.Entry entry : entries.entrySet()) { + output.putNextEntry(new ZipEntry(entry.getKey())); + output.write(entry.getValue()); + output.closeEntry(); + } + } + return path.toFile(); + } + + private String digest(String algorithm, File file) throws Exception { + byte[] digest = MessageDigest.getInstance(algorithm).digest(Files.readAllBytes(file.toPath())); + StringBuilder result = new StringBuilder(); + for (byte value : digest) { + result.append(String.format("%02x", value & 255)); + } + return result.toString(); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionPublicControllerTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionPublicControllerTest.java new file mode 100644 index 0000000..39039fc --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionPublicControllerTest.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.member.service.appversion; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.member.controller.app.appversion.AppAppVersionController; +import cn.iocoder.yudao.module.member.controller.app.appversion.vo.AppVersionCheckReqVO; +import cn.iocoder.yudao.module.member.controller.admin.appversion.vo.AppVersionRespVO; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import org.springframework.mock.web.MockHttpServletResponse; +import java.util.Collections; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AppVersionPublicControllerTest extends BaseMockitoUnitTest { + @InjectMocks private AppAppVersionController controller; + @Mock private AppVersionService service; + + @Test + void publicResponsesDisableCachingAndExcludeGrayMembership() throws Exception { + AppVersionCheckReqVO request = new AppVersionCheckReqVO(); + when(service.check(request)).thenReturn(new AppVersionRespVO().setMemberIds(Collections.singletonList(42L)).setMemberCount(1)); + MockHttpServletResponse response = new MockHttpServletResponse(); + String json = new ObjectMapper().writeValueAsString(controller.check(request, response).getData()); + assertTrue(response.getHeader("Cache-Control").contains("no-store")); + assertFalse(json.contains("memberIds")); + assertFalse(json.contains("members")); + assertFalse(json.contains("memberCount")); + MockHttpServletResponse validation = new MockHttpServletResponse(); + assertNull(controller.validate(request, validation).getData()); + assertTrue(validation.getHeader("Cache-Control").contains("no-store")); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionSchemaTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionSchemaTest.java new file mode 100644 index 0000000..ab1c3f4 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionSchemaTest.java @@ -0,0 +1,135 @@ +package cn.iocoder.yudao.module.member.service.appversion; + +import org.junit.jupiter.api.Test; +import java.nio.file.*; +import java.nio.charset.StandardCharsets; +import java.sql.*; +import java.util.UUID; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +/** 以 H2 MySQL 模式验证新表及有效唯一键,真实 MySQL 执行仍属于上线步骤。 */ +class AppVersionSchemaTest { + @Test + void repeatedMemberReplacementPreservesDeletionHistory() throws Exception { + try (Connection connection = schema()) { + Statement sql = connection.createStatement(); + for (int cycle = 0; cycle < 4; cycle++) { + sql.executeUpdate("INSERT INTO member_app_version_member (version_id, member_id, tenant_id) VALUES (1, 42, 1)"); + assertThrows(SQLException.class, () -> sql.executeUpdate( + "INSERT INTO member_app_version_member (version_id, member_id, tenant_id) VALUES (1, 42, 1)")); + sql.executeUpdate("UPDATE member_app_version_member SET deleted = 1 WHERE version_id = 1 AND member_id = 42 AND deleted = 0"); + } + try (ResultSet count = sql.executeQuery("SELECT COUNT(*) FROM member_app_version_member")) { + assertTrue(count.next()); + assertEquals(4, count.getInt(1)); + } + } + } + + @Test + void repeatedVersionDeleteAndRecreateIsAllowed() throws Exception { + try (Connection connection = schema()) { + Statement sql = connection.createStatement(); + for (int cycle = 0; cycle < 4; cycle++) { + sql.executeUpdate(insertVersion(1)); + assertThrows(SQLException.class, () -> sql.executeUpdate(insertVersion(1))); + sql.executeUpdate("UPDATE member_app_version SET deleted = 1 WHERE tenant_id = 1 AND deleted = 0"); + } + } + } + + @Test + void uniqueVersionAndMemberKeysRetainTenantDimension() throws Exception { + try (Connection connection = schema()) { + Statement sql = connection.createStatement(); + sql.executeUpdate(insertVersion(1)); + sql.executeUpdate(insertVersion(2)); + sql.executeUpdate("INSERT INTO member_app_version_member (version_id, member_id, tenant_id) VALUES (1, 42, 1)"); + sql.executeUpdate("INSERT INTO member_app_version_member (version_id, member_id, tenant_id) VALUES (1, 42, 2)"); + } + } + + @Test + void overlappingTargetsCannotUseTheSameVersionWhileDisjointTargetsCan() throws Exception { + try (Connection connection = schema(); Statement sql = connection.createStatement()) { + sql.executeUpdate(insertVersion(1, "android,ios", 135)); + assertThrows(SQLException.class, () -> sql.executeUpdate(insertVersion(1, "ios", 135))); + assertThrows(SQLException.class, () -> sql.executeUpdate(insertVersion(1, "android,harmony", 135))); + sql.executeUpdate(insertVersion(1, "harmony", 135)); + sql.executeUpdate("UPDATE member_app_version SET deleted = 1 WHERE tenant_id = 1 AND platform = 'android,ios'"); + sql.executeUpdate(insertVersion(1, "ios", 135)); + sql.executeUpdate(insertVersion(1, "android", 135)); + sql.executeUpdate(insertVersion(2, "android,ios,harmony", 135)); + } + } + + @Test + void concurrentOverlappingTargetsAreProtectedByDatabaseUniqueKeys() throws Exception { + ExecutorService worker = Executors.newSingleThreadExecutor(); + try (Connection connection = schema()) { + connection.setAutoCommit(false); + try (Statement sql = connection.createStatement()) { + sql.executeUpdate(insertVersion(1, "android,ios", 135)); + } + String url = connection.getMetaData().getURL(); + CountDownLatch started = new CountDownLatch(1); + Future competing = worker.submit(() -> { + try (Connection second = DriverManager.getConnection(url, "sa", ""); Statement sql = second.createStatement()) { + started.countDown(); + sql.executeUpdate(insertVersion(1, "ios", 135)); + return null; + } + }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + connection.commit(); + ExecutionException error = assertThrows(ExecutionException.class, () -> competing.get(5, TimeUnit.SECONDS)); + assertTrue(error.getCause() instanceof SQLException); + } finally { + worker.shutdownNow(); + } + } + + @Test + void generatedPlatformKeysUseExactTokens() throws Exception { + try (Connection connection = schema(); Statement sql = connection.createStatement()) { + sql.executeUpdate(insertVersion(1, "bios", 135)); + sql.executeUpdate(insertVersion(1, "ios", 135)); + try (ResultSet count = sql.executeQuery("SELECT COUNT(*) FROM member_app_version WHERE ios_active_flag = 1")) { + assertTrue(count.next()); + assertEquals(1, count.getInt(1)); + } + } + } + + private String insertVersion(int tenant) { + return insertVersion(tenant, "android", 135); + } + + private String insertVersion(int tenant, String platforms, int version) { + return "INSERT INTO member_app_version (package_id, app_id, platform, package_type, version_code, version_name, resource_version_code, update_content, tenant_id)" + + " VALUES (1, '__UNI__TEST', '" + platforms + "', 'wgt', " + version + ", '1.3.5', " + version + ", 'notes', " + tenant + ")"; + } + + private Connection schema() throws Exception { + Path root = Paths.get("").toAbsolutePath(); + String relative = "sql/mysql/app/20261111_app_version_management.sql"; + while (root != null && !Files.exists(root.resolve(relative))) { + root = root.getParent(); + } + assertNotNull(root); + String migration = new String(Files.readAllBytes(root.resolve(relative)), StandardCharsets.UTF_8); + String tables = migration.substring(0, migration.indexOf("-- 优先复用")) + .replace(" STORED", "").replace("bit(1)", "tinyint").replace("b'0'", "0") + .replaceAll(" ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='[^']*'", ""); + Connection connection = DriverManager.getConnection("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL", "sa", ""); + try (Statement statement = connection.createStatement()) { + for (String sql : tables.split(";")) { + if (!sql.trim().isEmpty()) { + statement.execute(sql); + } + } + } + return connection; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionServiceImplTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionServiceImplTest.java new file mode 100644 index 0000000..72008ae --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/appversion/AppVersionServiceImplTest.java @@ -0,0 +1,324 @@ +package cn.iocoder.yudao.module.member.service.appversion; + +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.infra.api.file.FileApi; +import cn.iocoder.yudao.module.member.controller.admin.appversion.vo.*; +import cn.iocoder.yudao.module.member.controller.app.appversion.vo.AppVersionCheckReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.appversion.*; +import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO; +import cn.iocoder.yudao.module.member.dal.mysql.appversion.*; +import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.Expression; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.*; +import org.mockito.*; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.io.File; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class AppVersionServiceImplTest extends BaseMockitoUnitTest { + @InjectMocks private AppVersionServiceImpl service; + @Mock private AppVersionMapper versionMapper; + @Mock private AppVersionPackageMapper packageMapper; + @Mock private AppVersionMemberMapper versionMemberMapper; + @Mock private MemberUserMapper memberUserMapper; + @Mock private FileApi fileApi; + @Mock private AppVersionPackageParser packageParser; + + @BeforeEach + void initializeLambdaMetadata() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), AppVersionDO.class); + } + + @AfterEach + void clearIdentity() { + SecurityContextHolder.clearContext(); + } + + @Test + void sameResourcePrefersCompatibleWgt() { + AppVersionDO apk = apk(20L, 120, 130); + AppVersionDO wgt = wgt(10L, 130, 100, 120); + assertEquals(wgt, service.selectCandidate(Arrays.asList(apk, wgt), installed(110, 110))); + } + + @Test + void incompatibleWgtUsesNativeUpgrade() { + AppVersionDO apk = apk(20L, 120, 130); + assertEquals(apk, service.selectCandidate(Arrays.asList(apk, wgt(30L, 130, 120, null)), installed(110, 110))); + } + + @Test + void nativeUpgradeCannotDowngradeResources() { + assertNull(service.selectCandidate(Collections.singletonList(apk(20L, 150, 100)), installed(110, 130))); + assertNull(service.selectCandidate(Collections.singletonList(apk(20L, 110, 150)), installed(110, 130))); + } + + @Test + void nativeUpgradeWithEqualResourcesIsAllowed() { + AppVersionDO apk = apk(20L, 120, 130); + assertEquals(apk, service.selectCandidate(Collections.singletonList(apk), installed(110, 130))); + } + + @Test + void wgtBoundsIncludeBothEdgesAndRejectSameResources() { + AppVersionDO wgt = wgt(10L, 130, 100, 120); + assertEquals(wgt, service.selectCandidate(Collections.singletonList(wgt), installed(100, 110))); + assertEquals(wgt, service.selectCandidate(Collections.singletonList(wgt), installed(120, 110))); + assertNull(service.selectCandidate(Collections.singletonList(wgt), installed(99, 110))); + assertNull(service.selectCandidate(Collections.singletonList(wgt), installed(121, 110))); + assertNull(service.selectCandidate(Collections.singletonList(wgt), installed(110, 130))); + } + + @Test + void apkMustMatchActualAndroidPackage() { + AppVersionDO apk = apk(20L, 120, 130); + assertNull(service.selectCandidate(Collections.singletonList(apk), installed(110, 110).setPackageName("another.app"))); + assertNull(service.selectCandidate(Collections.singletonList(apk), installed(110, 110).setPlatform("ios"))); + assertNull(service.selectCandidate(Collections.singletonList(apk), installed(110, 110).setPackageName(null))); + } + + @Test + void highestResourceWinsRegardlessOfPublicationOrder() { + AppVersionDO highest = wgt(10L, 140, 100, null); + assertEquals(highest, service.selectCandidate(Arrays.asList(wgt(50L, 130, 100, null), highest), installed(110, 110))); + } + + @Test + void guestQueryContainsOnlyFullReleaseScope() { + when(versionMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList()); + assertNull(service.check(installed(110, 110))); + ArgumentCaptor> query = ArgumentCaptor.forClass(Wrapper.class); + verify(versionMapper).selectList(query.capture()); + assertFalse(query.getValue().getSqlSegment().contains("EXISTS")); + verifyNoInteractions(memberUserMapper); + } + + @Test + void authenticatedGrayQueryUsesRealMemberAndTenantInterceptor() { + LoginUser login = new LoginUser().setId(42L).setUserType(UserTypeEnum.MEMBER.getValue()).setTenantId(2L); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(login, null, Collections.emptyList())); + when(memberUserMapper.selectById(42L)).thenReturn(new MemberUserDO().setId(42L).setStatus(0)); + when(versionMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList()); + service.check(installed(110, 110)); + ArgumentCaptor> query = ArgumentCaptor.forClass(Wrapper.class); + verify(versionMapper).selectList(query.capture()); + String sql = "SELECT * FROM member_app_version WHERE " + query.getValue().getSqlSegment().replaceAll("#\\{[^}]+}", "1"); + TenantLineInnerInterceptor interceptor = new TenantLineInnerInterceptor(new TenantLineHandler() { + @Override public Expression getTenantId() { return new LongValue(2); } + }); + String rewritten = interceptor.parserSingle(sql, null); + assertEquals(2, rewritten.split("tenant_id = 2", -1).length - 1, rewritten); + assertTrue(rewritten.contains("vm.tenant_id = 2")); + assertTrue(rewritten.contains("vm.member_id")); + assertTrue(rewritten.contains(" OR ")); + } + + @Test + void validateReturnsNullWhenTargetWasReplacedOrWithdrawn() { + when(versionMapper.selectList(any(Wrapper.class))).thenReturn(Collections.singletonList(wgt(2L, 140, 100, null).setPackageId(8L))); + when(packageMapper.selectById(8L)).thenReturn(pack().setId(8L)); + assertNull(service.validate(installed(110, 110).setId(1L))); + } + + @Test + void publishedVersionCannotBeEditedOrDeleted() { + when(versionMapper.selectForUpdate(1L)).thenReturn(wgt(1L, 120, 100, null).setStatus("published")); + assertThrows(ServiceException.class, () -> service.update(request().setId(1L))); + assertThrows(ServiceException.class, () -> service.delete(1L)); + verify(versionMapper, never()).deleteById(anyLong()); + } + + @Test + void membersMustExistInCurrentTenantAndBeEnabled() { + when(packageMapper.selectById(1L)).thenReturn(pack()); + when(memberUserMapper.selectBatchIds(anyCollection())).thenReturn(Collections.singletonList(new MemberUserDO().setId(42L).setStatus(1))); + assertThrows(ServiceException.class, () -> service.create(request().setReleaseScope("members").setMemberIds(Collections.singletonList(42L)))); + verify(versionMapper, never()).insert(any(AppVersionDO.class)); + } + + @Test + void nativeRangeRequiresMinimumAndOrderedBounds() { + when(packageMapper.selectById(1L)).thenReturn(pack()); + assertThrows(ServiceException.class, () -> service.create(request().setMinNativeVersionCode(null))); + assertThrows(ServiceException.class, () -> service.create(request().setMinNativeVersionCode(120).setMaxNativeVersionCode(110))); + } + + @Test + void publishedPromoteUsesVersionLockAndClearsMembers() { + when(versionMapper.selectForUpdate(1L)).thenReturn(wgt(1L, 120, 100, null).setStatus("published").setReleaseScope("members")); + service.promote(1L); + ArgumentCaptor updated = ArgumentCaptor.forClass(AppVersionDO.class); + verify(versionMapper).updateById(updated.capture()); + assertEquals("all", updated.getValue().getReleaseScope()); + verify(versionMemberMapper).delete(any(com.baomidou.mybatisplus.core.toolkit.support.SFunction.class), eq(1L)); + } + + @Test + void storageCapacityErrorRemainsVisibleAndTemporaryFileIsRemoved() throws Exception { + when(packageParser.parse(any(File.class), anyString(), anyString())).thenReturn(pack()); + when(fileApi.createFile(anyString(), anyString(), any(File.class), anyString())) + .thenThrow(new IllegalArgumentException("数据库文件存储最大支持16MB,请切换OSS")); + ServiceException error = assertThrows(ServiceException.class, + () -> service.upload(new MockMultipartFile("file", "update.wgt", "application/octet-stream", new byte[]{1}), "android")); + assertTrue(error.getMessage().contains("OSS")); + ArgumentCaptor file = ArgumentCaptor.forClass(File.class); + verify(fileApi).createFile(anyString(), anyString(), file.capture(), anyString()); + assertFalse(file.getValue().exists()); + } + + @Test + void persistenceFailureCompensatesRemoteFile() throws Exception { + when(packageParser.parse(any(File.class), anyString(), anyString())).thenReturn(pack()); + when(fileApi.createFile(anyString(), anyString(), any(File.class), anyString())).thenReturn("https://files.test/unique.wgt"); + when(packageMapper.insert(any(AppVersionPackageDO.class))).thenThrow(new IllegalStateException("test database failure")); + assertThrows(ServiceException.class, + () -> service.upload(new MockMultipartFile("file", "update.wgt", "application/octet-stream", new byte[]{1}), "android")); + verify(fileApi).deleteVersionPackageFile("https://files.test/unique.wgt"); + } + + @Test + void oneWgtReleaseUsesTheSamePackageAndUrlOnAndroidAndIos() { + AppVersionDO version = wgt(7L, 135, 100, null).setPlatform("android,ios").setPackageId(1L); + when(versionMapper.selectList(any(Wrapper.class))).thenReturn(Collections.singletonList(version)); + when(packageMapper.selectById(1L)).thenReturn(pack()); + for (String platform : Arrays.asList("android", "ios")) { + AppVersionCheckReqVO request = installed(110, 110).setPlatform(platform).setId(7L); + AppVersionRespVO available = service.check(request); + assertNotNull(available); + assertEquals(7L, available.getId()); + assertEquals(1L, available.getPackageId()); + assertEquals("https://files.test/update.wgt", available.getDownloadUrl()); + assertEquals(platform, available.getPlatform()); + assertEquals(Arrays.asList("android", "ios"), available.getPlatforms()); + assertEquals(7L, service.validate(request).getId()); + } + } + + @Test + void multiPlatformGrayReleaseUsesSameAuthenticatedMemberOnBothPlatforms() { + LoginUser login = new LoginUser().setId(42L).setUserType(UserTypeEnum.MEMBER.getValue()).setTenantId(2L); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(login, null, Collections.emptyList())); + when(memberUserMapper.selectById(42L)).thenReturn(new MemberUserDO().setId(42L).setStatus(0)); + when(versionMapper.selectList(any(Wrapper.class))).thenReturn(Collections.singletonList( + wgt(7L, 135, 100, null).setPlatform("android,ios").setPackageId(1L).setReleaseScope("members"))); + when(packageMapper.selectById(1L)).thenReturn(pack()); + assertEquals(7L, service.check(installed(110, 110)).getId()); + assertEquals(7L, service.check(installed(110, 110).setPlatform("ios")).getId()); + ArgumentCaptor> queries = ArgumentCaptor.forClass(Wrapper.class); + verify(versionMapper, times(2)).selectList(queries.capture()); + for (Wrapper query : queries.getAllValues()) { + assertTrue(query.getSqlSegment().contains("FIND_IN_SET")); + assertTrue(query.getSqlSegment().contains("vm.member_id")); + } + } + + @Test + void multiPlatformSaveCreatesOneNormalizedRelease() { + when(packageMapper.selectById(1L)).thenReturn(pack()); + when(versionMapper.insert(any(AppVersionDO.class))).thenAnswer(invocation -> { + ((AppVersionDO) invocation.getArgument(0)).setId(7L); + return 1; + }); + assertEquals(7L, service.create(request().setPlatforms(Arrays.asList("ios", "android", "ios")))); + ArgumentCaptor saved = ArgumentCaptor.forClass(AppVersionDO.class); + verify(versionMapper, times(1)).insert(saved.capture()); + assertEquals("android,ios", saved.getValue().getPlatform()); + assertEquals(1L, saved.getValue().getPackageId()); + } + + @Test + void legacySavePreservesSinglePlatformAndUpdateKeepsExistingTargets() { + when(packageMapper.selectById(1L)).thenReturn(pack().setPlatform("android,ios")); + when(versionMapper.selectForUpdate(7L)).thenReturn(wgt(7L, 120, 100, null).setPlatform("ios").setStatus("draft")); + service.update(request().setId(7L)); + ArgumentCaptor saved = ArgumentCaptor.forClass(AppVersionDO.class); + verify(versionMapper).update(saved.capture(), any(Wrapper.class)); + assertEquals("ios", saved.getValue().getPlatform()); + when(packageMapper.selectById(1L)).thenReturn(pack().setPlatform("android")); + when(versionMapper.insert(any(AppVersionDO.class))).thenAnswer(invocation -> { + ((AppVersionDO) invocation.getArgument(0)).setId(8L); + return 1; + }); + service.create(request()); + verify(versionMapper).insert(argThat((AppVersionDO version) -> "android".equals(version.getPlatform()))); + } + + @Test + void apkTargetsMustBeExactlyAndroidAndEmptyTargetsAreRejected() { + when(packageMapper.selectById(1L)).thenReturn(pack().setPackageType("apk").setNativeVersionCode(120)); + assertThrows(ServiceException.class, () -> service.create(request().setPlatforms(Arrays.asList("android", "ios")))); + assertThrows(ServiceException.class, () -> service.create(request().setPlatforms(Collections.singletonList("ios")))); + assertThrows(ServiceException.class, () -> service.create(request().setPlatforms(Collections.emptyList()))); + verify(versionMapper, never()).insert(any(AppVersionDO.class)); + } + + @Test + void uploadWithoutPlatformDefaultsWgtToAndroidAndIos() throws Exception { + when(packageParser.parse(any(File.class), anyString(), eq("android"))).thenReturn(pack()); + when(fileApi.createFile(anyString(), anyString(), any(File.class), anyString())).thenReturn("https://files.test/shared.wgt"); + AppVersionPackageRespVO result = service.upload( + new MockMultipartFile("file", "shared.wgt", "application/octet-stream", new byte[]{1}), null); + assertEquals(Arrays.asList("android", "ios"), result.getPlatforms()); + assertEquals("android,ios", result.getPlatform()); + verify(packageMapper).insert(argThat((AppVersionPackageDO pack) -> "android,ios".equals(pack.getPlatform()))); + } + + @Test + void uploadLegacySinglePlatformRemainsSupported() throws Exception { + when(packageParser.parse(any(File.class), anyString(), eq("ios"))).thenReturn(pack()); + when(fileApi.createFile(anyString(), anyString(), any(File.class), anyString())).thenReturn("https://files.test/ios.wgt"); + AppVersionPackageRespVO result = service.upload( + new MockMultipartFile("file", "shared.wgt", "application/octet-stream", new byte[]{1}), "ios"); + assertEquals(Collections.singletonList("ios"), result.getPlatforms()); + } + + @Test + void platformMatchingUsesWholeSetMembers() { + AppVersionDO version = wgt(7L, 135, 100, null).setPlatform("android,ios"); + assertNotNull(service.selectCandidate(Collections.singletonList(version), installed(110, 110).setPlatform("ios"))); + assertNull(service.selectCandidate(Collections.singletonList(version), installed(110, 110).setPlatform("os"))); + assertNull(service.selectCandidate(Collections.singletonList(version.setPlatform("android,ios-legacy")), installed(110, 110).setPlatform("ios"))); + } + + private AppVersionCheckReqVO installed(int nativeCode, int resourceCode) { + return new AppVersionCheckReqVO().setAppId("__UNI__0A84F60").setPlatform("android") + .setNativeVersionCode(nativeCode).setResourceVersionCode(resourceCode).setPackageName("com.friendship.chat.test"); + } + + private AppVersionDO wgt(long id, int resource, int minimum, Integer maximum) { + return new AppVersionDO().setId(id).setPlatform("android").setAppId("__UNI__0A84F60").setPackageType("wgt") + .setVersionCode(resource).setResourceVersionCode(resource).setMinNativeVersionCode(minimum).setMaxNativeVersionCode(maximum); + } + + private AppVersionDO apk(long id, int nativeCode, int resource) { + return new AppVersionDO().setId(id).setPlatform("android").setPackageType("apk").setNativeVersionCode(nativeCode) + .setResourceVersionCode(resource).setPackageName("com.friendship.chat.test"); + } + + private AppVersionPackageDO pack() { + return new AppVersionPackageDO().setId(1L).setAppId("__UNI__0A84F60").setPlatform("android").setPackageType("wgt") + .setFileName("update.wgt").setFileSize(1L).setResourceVersionCode(120).setVersionCode(120).setVersionName("1.2.0") + .setDownloadUrl("https://files.test/update.wgt"); + } + + private AppVersionSaveReqVO request() { + return new AppVersionSaveReqVO().setPackageId(1L).setUpdateContent("更新说明").setForceUpdate(false) + .setReleaseScope("all").setMinNativeVersionCode(100); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/push/JPushGatewayTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/push/JPushGatewayTest.java new file mode 100644 index 0000000..47bc9f2 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/push/JPushGatewayTest.java @@ -0,0 +1,109 @@ +package cn.iocoder.yudao.module.member.service.push; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import cn.iocoder.yudao.module.member.dal.dataobject.push.*; +import cn.iocoder.yudao.module.member.framework.web.config.JPushProperties; +import cn.jiguang.common.resp.APIConnectionException; +import cn.jiguang.common.resp.APIRequestException; +import cn.jpush.api.JPushClient; +import cn.jpush.api.push.PushResult; +import org.junit.jupiter.api.*; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDateTime; +import java.util.Collections; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class JPushGatewayTest { + private final JPushGateway gateway = new JPushGateway(); + private final JPushClient client = mock(JPushClient.class); + private final JPushProperties properties = new JPushProperties(); + + @BeforeEach + void setUp() { + properties.setEnabled(true); + properties.setAppKey("isolated-test-app"); + properties.setMasterSecret("isolated-test-secret"); + properties.setApnsProduction(true); + ReflectionTestUtils.setField(gateway, "properties", properties); + ReflectionTestUtils.setField(gateway, "client", client); + } + + @Test + void notificationExtrasAndIosTitleAreStoredInExactSubmissionPayload() throws Exception { + MemberPushTaskDO task = task("AUTO"); + JSONObject payload = JSONUtil.parseObj(gateway.payload(task, Collections.singletonList(record()), "fixed-cid")); + assertEquals("fixed-cid", payload.getStr("cid")); + assertEquals("registration-one", payload.getJSONObject("audience").getJSONArray("registration_id").getStr(0)); + JSONObject android = payload.getJSONObject("notification").getJSONObject("android"); + JSONObject ios = payload.getJSONObject("notification").getJSONObject("ios"); + assertEquals("htys_message", android.getStr("channel_id")); + assertEquals("发送者", ios.getJSONObject("alert").getStr("title")); + assertEquals("消息正文", ios.getJSONObject("alert").getStr("body")); + assertEquals(android.getJSONObject("extras"), ios.getJSONObject("extras")); + assertEquals("12", ios.getJSONObject("extras").getStr("receiverId")); + assertEquals("11", ios.getJSONObject("extras").getStr("targetId")); + assertEquals("private_message", ios.getJSONObject("extras").getStr("type")); + assertTrue(payload.getJSONObject("options").getBool("apns_production")); + PushResult response = new PushResult(); + response.msg_id = 123L; + when(client.sendPush(payload.toString())).thenReturn(response); + assertEquals("123", gateway.send(payload.toString())); + verify(client).sendPush(payload.toString()); + } + + @Test + void manualPayloadUsesSharedTaskDestination() { + JSONObject payload = JSONUtil.parseObj(gateway.payload(task("MANUAL"), Collections.singletonList(record()), "fixed-cid")); + JSONObject extras = payload.getJSONObject("notification").getJSONObject("android").getJSONObject("extras"); + assertEquals("manual", extras.getStr("type")); + assertEquals("71", extras.getStr("taskId")); + assertFalse(extras.containsKey("receiverId")); + } + + @Test + void networkAndServerErrorsPreserveCidWhileRateLimitCanRetryAudience() throws Exception { + when(client.sendPush(anyString())).thenThrow(new APIConnectionException("test", null)); + JPushGateway.PushFailure connection = assertThrows(JPushGateway.PushFailure.class, () -> gateway.send("{}")); + assertTrue(connection.isRetryable()); + assertTrue(connection.isUncertain()); + for (int status : new int[] {500, 429, 400, 401}) { + APIRequestException failure = mock(APIRequestException.class); + when(failure.getStatus()).thenReturn(status); + when(failure.getErrorMessage()).thenReturn("isolated failure"); + doThrow(failure).when(client).sendPush(anyString()); + JPushGateway.PushFailure result = assertThrows(JPushGateway.PushFailure.class, () -> gateway.send("{}")); + assertEquals(status == 429 || status >= 500, result.isRetryable()); + assertEquals(status >= 500, result.isUncertain()); + } + } + + @Test + void disabledConfigurationNeverCallsJpush() { + properties.setEnabled(false); + JPushGateway.PushFailure failure = assertThrows(JPushGateway.PushFailure.class, gateway::allocateCid); + assertFalse(failure.isRetryable()); + verifyNoInteractions(client); + } + + private MemberPushTaskDO task(String source) { + MemberPushTaskDO task = new MemberPushTaskDO(); + task.setId(71L); + task.setSource(source); + task.setMessageId(7L); + task.setSenderId(11L); + task.setReceiverId(12L); + task.setTitle("发送者"); + task.setContent("消息正文"); + task.setExpiresAt(LocalDateTime.now().plusHours(24)); + return task; + } + + private MemberPushRecordDO record() { + MemberPushRecordDO record = new MemberPushRecordDO(); + record.setRegistrationId("registration-one"); + return record; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/push/MemberPushTaskServiceMySqlTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/push/MemberPushTaskServiceMySqlTest.java new file mode 100644 index 0000000..8cd691c --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/push/MemberPushTaskServiceMySqlTest.java @@ -0,0 +1,796 @@ +package cn.iocoder.yudao.module.member.service.push; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.mybatis.core.handler.DefaultDBFieldHandler; +import cn.iocoder.yudao.framework.tenant.config.TenantProperties; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.tenant.core.db.TenantDatabaseInterceptor; +import cn.iocoder.yudao.module.member.client.service.ChatApi; +import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivatePushContextRespVO; +import cn.iocoder.yudao.module.member.controller.admin.push.vo.MemberPushPageReqVO; +import cn.iocoder.yudao.module.member.controller.admin.push.vo.MemberPushSendReqVO; +import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserPageReqVO; +import cn.iocoder.yudao.module.member.controller.app.push.vo.AppPushBindReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushDeviceDO; +import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushRecordDO; +import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushTaskDO; +import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO; +import cn.iocoder.yudao.module.member.dal.mysql.push.MemberPushDeviceMapper; +import cn.iocoder.yudao.module.member.dal.mysql.push.MemberPushRecordMapper; +import cn.iocoder.yudao.module.member.dal.mysql.push.MemberPushTaskMapper; +import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper; +import cn.iocoder.yudao.module.member.framework.web.config.JPushProperties; +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import com.baomidou.mybatisplus.core.config.GlobalConfig; +import com.baomidou.mybatisplus.core.metadata.TableFieldInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; +import org.apache.ibatis.mapping.Environment; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.mybatis.spring.transaction.SpringManagedTransactionFactory; +import org.redisson.api.RedissonClient; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.TransactionSystemException; +import org.springframework.transaction.support.DefaultTransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.sql.Statement; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Uses a unique disposable localhost schema and mocks every outbound IM/JPush call. */ +class MemberPushTaskServiceMySqlTest { + + private static final String TEST_APP_KEY = "test-app-key"; + private JdbcTemplate admin; + private JdbcTemplate jdbc; + private String database; + private MemberPushTaskService service; + private MemberPushTaskMapper tasks; + private MemberPushRecordMapper records; + private MemberPushDeviceMapper devices; + private MemberUserMapper users; + private JPushGateway gateway; + private ChatApi chat; + private MemberPushDeviceServiceImpl deviceService; + private JPushProperties properties; + private CommitFailureTransactionManager transactionManager; + + @BeforeEach + void setUp() throws Exception { + String url = System.getenv("MEMBER_PUSH_TEST_MYSQL_URL"); + assumeTrue(url != null && !url.isEmpty(), "Set MEMBER_PUSH_TEST_MYSQL_URL for disposable MySQL tests"); + assertTrue(url.matches("jdbc:mysql://(localhost|127\\.0\\.0\\.1):[0-9]+/[a-zA-Z0-9_]+(?:\\?.*)?")); + String password = System.getenv().getOrDefault("MEMBER_PUSH_TEST_MYSQL_PASSWORD", ""); + admin = new JdbcTemplate(new UnpooledDataSource("com.mysql.cj.jdbc.Driver", url, "root", password)); + database = "codex_member_push_" + UUID.randomUUID().toString().replace("-", ""); + admin.execute("CREATE DATABASE " + database + " CHARACTER SET utf8mb4"); + int queryIndex = url.indexOf('?'); + String base = queryIndex < 0 ? url : url.substring(0, queryIndex); + String schemaUrl = base.substring(0, base.lastIndexOf('/') + 1) + database + + (queryIndex < 0 ? "" : url.substring(queryIndex)); + DataSource dataSource = new UnpooledDataSource("com.mysql.cj.jdbc.Driver", schemaUrl, "root", password); + jdbc = new JdbcTemplate(dataSource); + runTableDdl("member_push_device.sql"); + runTableDdl("20261111_member_push_management.sql"); + + transactionManager = new CommitFailureTransactionManager(dataSource); + transactionManager.setRollbackOnCommitFailure(true); + MybatisConfiguration configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.setEnvironment(new Environment("push-test", new SpringManagedTransactionFactory(), dataSource)); + GlobalConfigUtils.setGlobalConfig(configuration, new GlobalConfig() + .setDbConfig(new GlobalConfig.DbConfig().setIdType(IdType.AUTO)) + .setMetaObjectHandler(new DefaultDBFieldHandler())); + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(new TenantProperties()))); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + configuration.addInterceptor(interceptor); + configuration.addMapper(MemberUserMapper.class); + configuration.addMapper(MemberPushTaskMapper.class); + configuration.addMapper(MemberPushRecordMapper.class); + configuration.addMapper(MemberPushDeviceMapper.class); + SqlSessionTemplate sessions = new SqlSessionTemplate(new MybatisSqlSessionFactoryBuilder().build(configuration)); + createMemberTable(); + users = sessions.getMapper(MemberUserMapper.class); + tasks = sessions.getMapper(MemberPushTaskMapper.class); + records = sessions.getMapper(MemberPushRecordMapper.class); + devices = sessions.getMapper(MemberPushDeviceMapper.class); + gateway = mock(JPushGateway.class); + chat = mock(ChatApi.class); + when(gateway.getAppKey()).thenReturn(TEST_APP_KEY); + when(gateway.allocateCid()).thenReturn("cid-first", "cid-second", "cid-third"); + when(gateway.payload(any(), anyList(), anyString())).thenAnswer(invocation -> { + List batch = invocation.getArgument(1); + return invocation.getArgument(2) + ":" + batch.stream().map(MemberPushRecordDO::getRegistrationId) + .collect(Collectors.joining(",")); + }); + when(gateway.send(anyString())).thenReturn("jpush-message"); + service = newService(); + properties = new JPushProperties(); + properties.setAppKey(TEST_APP_KEY); + deviceService = new MemberPushDeviceServiceImpl(); + ReflectionTestUtils.setField(deviceService, "pushDeviceMapper", devices); + ReflectionTestUtils.setField(deviceService, "jPushProperties", properties); + TenantContextHolder.setTenantId(1L); + insertMember(101L, 1L); + insertMember(102L, 1L); + insertMember(201L, 2L); + } + + @AfterEach + void tearDown() { + TenantContextHolder.clear(); + if (admin != null && database != null) admin.execute("DROP DATABASE " + database); + } + + @Test + void manualRequestsAreIdempotentAndIsolatedByTenant() { + MemberPushSendReqVO request = request("USER", 101L); + Long first = service.createManual(request, 9L); + assertEquals(first, service.createManual(request, 9L)); + request.setTitle("不同标题"); + assertThrows(ServiceException.class, () -> service.createManual(request, 9L)); + + service.process(first); + assertEquals(1L, service.page(new MemberPushPageReqVO()).getTotal()); + TenantContextHolder.setTenantId(2L); + assertThrows(ServiceException.class, () -> service.get(first)); + assertEquals(0L, service.page(new MemberPushPageReqVO()).getTotal()); + request.setMemberIds(Collections.singletonList(201L)); + request.setTitle("通知标题"); + Long second = service.createManual(request, 9L); + assertNotEquals(first, second); + assertEquals(1L, service.page(new MemberPushPageReqVO()).getTotal()); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM member_push_record WHERE tenant_id=2", Integer.class)); + } + + @Test + void concurrentManualRetriesCreateOneTask() throws Exception { + MemberPushSendReqVO request = request("USER", 101L); + ExecutorService executor = Executors.newFixedThreadPool(6); + CountDownLatch start = new CountDownLatch(1); + List> calls = new ArrayList<>(); + try { + for (int index = 0; index < 6; index++) { + calls.add(executor.submit(() -> { + TenantContextHolder.setTenantId(1L); + try { + start.await(); + return service.createManual(request, 9L); + } finally { + TenantContextHolder.clear(); + } + })); + } + start.countDown(); + Long taskId = calls.get(0).get(10, TimeUnit.SECONDS); + for (Future call : calls) assertEquals(taskId, call.get(10, TimeUnit.SECONDS)); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM member_push_task", Integer.class)); + } finally { + executor.shutdownNow(); + } + } + + @Test + void allAudienceUsesThousandDevicePagesAndExcludesLaterBindingsAndOtherTenants() { + insertDevices(101L, 1L, 1001); + insertDevices(201L, 2L, 1); + Long taskId = service.createManual(request("ALL"), 9L); + bind(102L, 1L, "late-registration"); + + service.process(taskId); + assertFalse(service.get(taskId).getAudienceComplete()); + assertEquals(1000, service.get(taskId).getTotalDevices()); + service.process(taskId); + assertTrue(service.get(taskId).getAudienceComplete()); + assertEquals(1001, service.get(taskId).getTotalDevices()); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM member_push_record WHERE member_id IN (102,201)", Integer.class)); + + service.process(taskId); + service.process(taskId); + MemberPushTaskDO task = service.get(taskId); + assertEquals("COMPLETED", task.getStatus()); + assertEquals(1001, task.getSubmittedCount()); + assertEquals(1001, task.getProcessedCount()); + verify(gateway, times(2)).allocateCid(); + verify(gateway, times(2)).send(anyString()); + } + + @Test + void audienceCommitFailurePreservesCursorAndReplaysRolledBackBatch() { + bind(101L, 1L, "registration-one"); + Long taskId = service.createManual(request("ALL"), 9L); + transactionManager.failNextCommit = true; + service.process(taskId); + + MemberPushTaskDO rolledBack = service.get(taskId); + assertEquals(0L, rolledBack.getAudienceCursor()); + assertFalse(rolledBack.getAudienceComplete()); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM member_push_record", Integer.class)); + assertEquals(1, rolledBack.getAttemptCount()); + makeDue(taskId); + service.process(taskId); + assertEquals(1, service.get(taskId).getTotalDevices()); + assertTrue(service.get(taskId).getAudienceComplete()); + } + + @Test + void uncertainBatchSurvivesServiceRecreationWithOriginalCidAndPayload() { + bind(101L, 1L, "registration-one"); + Long taskId = service.createManual(request("USER", 101L), 9L); + service.process(taskId); + when(gateway.send(anyString())) + .thenThrow(new JPushGateway.PushFailure("CONNECTION_UNCERTAIN", "response lost", true, true)) + .thenReturn("confirmed-message"); + service.process(taskId); + MemberPushRecordDO pending = onlyRecord(taskId); + assertEquals("SENDING", pending.getStatus()); + assertEquals("cid-first", pending.getCid()); + String payload = pending.getBatchPayload(); + + service = newService(); + makeDue(taskId); + service.process(taskId); + MemberPushRecordDO completed = onlyRecord(taskId); + assertEquals("SUBMITTED", completed.getStatus()); + assertEquals(2, completed.getAttemptCount()); + assertEquals("confirmed-message", completed.getJpushMsgId()); + assertEquals("COMPLETED", service.get(taskId).getStatus()); + assertEquals("", service.get(taskId).getLastError()); + verify(gateway, times(1)).allocateCid(); + verify(gateway, times(2)).send(payload); + } + + @Test + void uncertainBatchStopsWhenDeviceMovesToAnotherAccount() { + bind(101L, 1L, "registration-one"); + Long taskId = service.createManual(request("USER", 101L), 9L); + service.process(taskId); + when(gateway.send(anyString())).thenThrow( + new JPushGateway.PushFailure("CONNECTION_UNCERTAIN", "response lost", true, true)); + service.process(taskId); + assertEquals("SENDING", onlyRecord(taskId).getStatus()); + bind(102L, 1L, "registration-one"); + makeDue(taskId); + service.process(taskId); + assertEquals("FAILED", service.get(taskId).getStatus()); + assertEquals("TARGET_CHANGED_UNCONFIRMED", onlyRecord(taskId).getErrorCode()); + assertEquals("cid-first", onlyRecord(taskId).getCid()); + verify(gateway, times(1)).send(anyString()); + } + + @Test + void uncertainPrivateBatchRechecksCurrentMessagePolicy() { + bind(101L, 1L, "registration-one"); + ImPrivatePushEvent event = privateEvent(102L, 101L); + Long taskId = service.acceptPrivate(event); + service.process(taskId); + ChatPrivatePushContextRespVO context = new ChatPrivatePushContextRespVO(); + context.setEligible(true); + context.setMessageId(event.getMessageId()); + context.setSendId(event.getSendId()); + context.setRecvId(event.getRecvId()); + when(chat.privatePushContext(event.getMessageId())).thenReturn(context); + when(gateway.send(anyString())).thenThrow( + new JPushGateway.PushFailure("CONNECTION_UNCERTAIN", "response lost", true, true)); + service.process(taskId); + assertEquals("SENDING", onlyRecord(taskId).getStatus()); + context.setEligible(false); + context.setSkipReason("DND"); + makeDue(taskId); + service.process(taskId); + assertEquals("FAILED", service.get(taskId).getStatus()); + assertEquals("POLICY_CHANGED_UNCONFIRMED", onlyRecord(taskId).getErrorCode()); + verify(gateway, times(1)).send(anyString()); + } + + @Test + void uncertainPrivateBatchWaitsWhileDeviceIsForegroundAndKeepsOriginalCid() { + bind(101L, 1L, "registration-one"); + ImPrivatePushEvent event = privateEvent(102L, 101L); + Long taskId = service.acceptPrivate(event); + service.process(taskId); + ChatPrivatePushContextRespVO context = new ChatPrivatePushContextRespVO(); + context.setEligible(true); + context.setMessageId(event.getMessageId()); + context.setSendId(event.getSendId()); + context.setRecvId(event.getRecvId()); + when(chat.privatePushContext(event.getMessageId())).thenReturn(context); + when(gateway.send(anyString())) + .thenThrow(new JPushGateway.PushFailure("CONNECTION_UNCERTAIN", "response lost", true, true)) + .thenReturn("confirmed-message"); + service.process(taskId); + String payload = onlyRecord(taskId).getBatchPayload(); + context.setForegroundDevices(foreground("active-session", 20L).getForegroundDevices()); + makeDue(taskId); + service.process(taskId); + assertEquals("SENDING", onlyRecord(taskId).getStatus()); + assertEquals(1, onlyRecord(taskId).getAttemptCount()); + verify(gateway, times(1)).send(payload); + context.setForegroundDevices(Collections.emptyList()); + makeDue(taskId); + service.process(taskId); + assertEquals("COMPLETED", service.get(taskId).getStatus()); + verify(gateway, times(1)).allocateCid(); + verify(gateway, times(2)).send(payload); + } + + @Test + void explicitRejectionRechecksOwnershipBeforeRetrying() { + bind(101L, 1L, "registration-one"); + Long taskId = service.createManual(request("USER", 101L), 9L); + service.process(taskId); + when(gateway.send(anyString())).thenThrow(new JPushGateway.PushFailure("429", "rate limited", true, false)); + service.process(taskId); + assertEquals("WAITING", onlyRecord(taskId).getStatus()); + assertNull(onlyRecord(taskId).getCid()); + + bind(102L, 1L, "registration-one"); + makeDue(taskId); + service.process(taskId); + assertEquals("SKIPPED", service.get(taskId).getStatus()); + assertEquals("DEVICE_UNBOUND", onlyRecord(taskId).getErrorCode()); + verify(gateway, times(1)).send(anyString()); + } + + @Test + void memberWithoutDeviceHasZeroDeviceProgressAndVisibleReason() { + Long taskId = service.createManual(request("USER", 101L), 9L); + service.process(taskId); + MemberPushTaskDO task = service.get(taskId); + assertEquals("SKIPPED", task.getStatus()); + assertEquals(1, task.getTotalMembers()); + assertEquals(0, task.getTotalDevices()); + assertEquals(0, task.getProcessedCount()); + assertEquals(0, task.getSkippedCount()); + assertEquals("NO_DEVICE", onlyRecord(taskId).getErrorCode()); + verify(gateway, never()).send(anyString()); + } + + @Test + void expiryPreservesAlreadySubmittedDevicesAndFailsOnlyPendingOnes() { + bind(101L, 1L, "registration-one"); + bind(102L, 1L, "registration-two"); + Long taskId = service.createManual(request("USER", 101L, 102L), 9L); + service.process(taskId); + jdbc.update("UPDATE member_push_record SET next_retry_time=? WHERE task_id=? AND member_id=102", + java.sql.Timestamp.valueOf(LocalDateTime.now().plusHours(1)), taskId); + service.process(taskId); + assertEquals(1, service.get(taskId).getSubmittedCount()); + jdbc.update("UPDATE member_push_task SET expires_at=? WHERE id=?", + java.sql.Timestamp.valueOf(LocalDateTime.now().minusSeconds(1)), taskId); + makeDue(taskId); + service.process(taskId); + MemberPushTaskDO task = service.get(taskId); + assertEquals("PARTIAL_FAILED", task.getStatus()); + assertEquals(1, task.getSubmittedCount()); + assertEquals(1, task.getFailedCount()); + assertEquals(2, task.getProcessedCount()); + verify(gateway, times(1)).send(anyString()); + } + + @Test + void foregroundSuppressionRequiresNewerHeartbeatInSameSession() { + bind(101L, 1L, "registration-one"); + Long taskId = service.createManual(request("USER", 101L), 9L); + service.process(taskId); + MemberPushRecordDO record = onlyRecord(taskId); + ChatPrivatePushContextRespVO context = foreground("session-one", 10L); + assertTrue(service.deferForeground(record, context)); + assertEquals("WAITING", onlyRecord(taskId).getStatus()); + assertTrue(service.deferForeground(record, context)); + assertEquals("WAITING", onlyRecord(taskId).getStatus()); + + context = foreground("session-two", 20L); + assertTrue(service.deferForeground(record, context)); + assertEquals("WAITING", onlyRecord(taskId).getStatus()); + assertEquals("session-two", onlyRecord(taskId).getForegroundSession()); + context.getForegroundDevices().get(0).setHeartbeatVersion(21L); + assertTrue(service.deferForeground(record, context)); + assertEquals("SKIPPED", onlyRecord(taskId).getStatus()); + assertEquals("FOREGROUND", onlyRecord(taskId).getErrorCode()); + } + + @Test + void privateCallbacksUseReceiverTenantAndSkipMessagesAlreadyRead() { + insertMember(202L, 2L); + bind(202L, 2L, "receiver-registration"); + ImPrivatePushEvent event = privateEvent(201L, 202L); + Long taskId = service.acceptPrivate(event); + assertEquals(taskId, service.acceptPrivate(event)); + assertEquals(1L, TenantContextHolder.getTenantId()); + assertThrows(ServiceException.class, () -> service.get(taskId)); + + TenantContextHolder.setTenantId(2L); + assertEquals("AUTO", service.get(taskId).getSource()); + service.process(taskId); + ChatPrivatePushContextRespVO context = new ChatPrivatePushContextRespVO(); + context.setEligible(false); + context.setSkipReason("MESSAGE_READ"); + when(chat.privatePushContext(event.getMessageId())).thenReturn(context); + service.process(taskId); + assertEquals("SKIPPED", service.get(taskId).getStatus()); + assertEquals("MESSAGE_READ", onlyRecord(taskId).getErrorCode()); + verify(gateway, never()).send(anyString()); + } + + @Test + void foregroundBaselineFallsBackToPushWhenLeaseDisappears() { + bind(101L, 1L, "registration-one"); + ImPrivatePushEvent event = privateEvent(102L, 101L); + Long taskId = service.acceptPrivate(event); + service.process(taskId); + ChatPrivatePushContextRespVO context = foreground("foreground-session", 10L); + context.setEligible(true); + context.setMessageId(event.getMessageId()); + context.setSendId(event.getSendId()); + context.setRecvId(event.getRecvId()); + when(chat.privatePushContext(event.getMessageId())).thenReturn(context); + service.process(taskId); + assertEquals("WAITING", onlyRecord(taskId).getStatus()); + verify(gateway, never()).send(anyString()); + + context.setForegroundDevices(Collections.emptyList()); + makeDue(taskId); + service.process(taskId); + assertEquals("SUBMITTED", onlyRecord(taskId).getStatus()); + assertEquals("COMPLETED", service.get(taskId).getStatus()); + verify(gateway, times(1)).send(anyString()); + } + + @Test + void bindingMovesAtomicallyAcrossTenantsAndOldLogoutCannotUnbindNewOwner() { + bind(101L, 1L, "shared-registration"); + TenantContextHolder.setTenantId(2L); + bind(201L, 2L, "shared-registration"); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM member_push_device", Integer.class)); + assertEquals(201L, devices.selectByRegistrationId("shared-registration").getMemberId()); + + TenantContextHolder.setTenantId(1L); + assertNull(devices.selectByRegistrationId("shared-registration")); + assertEquals(0, devices.unbindDevice(101L, "shared-registration")); + TenantContextHolder.setTenantId(2L); + jdbc.update("UPDATE member_push_device SET deleted=1 WHERE registration_id='shared-registration'"); + bind(201L, 2L, "shared-registration"); + assertEquals(201L, devices.selectByRegistrationId("shared-registration").getMemberId()); + assertEquals(1, devices.unbindDevice(201L, "shared-registration")); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM member_push_device", Integer.class)); + } + + @Test + void currentApplicationAloneDeterminesAudienceDeviceCountsAndDeviceQueries() { + bind(101L, 1L, "current-registration"); + bindForApp(101L, 1L, "legacy-registration", ""); + bindForApp(101L, 1L, "old-registration", "old-app-key"); + bindForApp(101L, 1L, "case-mismatch-registration", TEST_APP_KEY.toUpperCase(java.util.Locale.ROOT)); + bindForApp(102L, 1L, "other-member-old-registration", "old-app-key"); + + assertEquals(1, deviceService.getByMemberId(101L).size()); + assertTrue(deviceService.getByMemberId(102L).isEmpty()); + java.util.Map counts = service.users(new MemberUserPageReqVO()).getList().stream() + .collect(Collectors.toMap(user -> user.getId(), user -> user.getDeviceCount())); + assertEquals(1L, counts.get(101L)); + assertEquals(0L, counts.get(102L)); + + Long taskId = service.createManual(request("ALL"), 9L); + assertEquals(devices.selectByRegistrationId("current-registration").getId(), service.get(taskId).getSnapshotMaxId()); + service.process(taskId); + assertEquals(1, service.get(taskId).getTotalDevices()); + assertEquals("current-registration", onlyRecord(taskId).getRegistrationId()); + Long noCurrentDevice = service.createManual(request("USER", 102L), 9L); + service.process(noCurrentDevice); + assertEquals("SKIPPED", service.get(noCurrentDevice).getStatus()); + assertEquals("NO_DEVICE", onlyRecord(noCurrentDevice).getErrorCode()); + assertEquals(5, jdbc.queryForObject("SELECT COUNT(*) FROM member_push_device", Integer.class)); + + properties.setAppKey(null); + assertTrue(deviceService.getByMemberId(101L).isEmpty()); + when(gateway.getAppKey()).thenReturn(""); + assertTrue(service.users(new MemberUserPageReqVO()).getList().stream() + .allMatch(user -> user.getDeviceCount() == 0)); + + properties.setAppKey(TEST_APP_KEY); + when(gateway.getAppKey()).thenReturn(TEST_APP_KEY); + TenantContextHolder.setIgnore(true); + bindForApp(201L, 2L, "foreign-old-registration", "old-app-key"); + bindForApp(201L, 2L, "foreign-unknown-registration", ""); + Long withoutTenantIsolation = service.createManual(request("ALL"), 9L); + service.process(withoutTenantIsolation); + assertEquals(1, service.get(withoutTenantIsolation).getTotalDevices()); + assertEquals("current-registration", onlyRecord(withoutTenantIsolation).getRegistrationId()); + assertTrue(deviceService.getByMemberId(201L).isEmpty()); + } + + @Test + void bindingPreservesLegacySuccessRejectsWrongApplicationAndMovesCurrentBindingAtomically() { + AppPushBindReqVO request = new AppPushBindReqVO(); + request.setRegistrationId("shared-registration"); + request.setPlatform("Android"); + deviceService.bind(101L, request); + MemberPushDeviceDO legacy = devices.selectByRegistrationId("shared-registration"); + assertEquals("", legacy.getAppKey()); + assertTrue(deviceService.getByMemberId(101L).isEmpty()); + + request.setAppKey("wrong-app-key"); + ServiceException wrongKey = assertThrows(ServiceException.class, () -> deviceService.bind(102L, request)); + assertEquals(400, wrongKey.getCode()); + assertEquals(101L, devices.selectByRegistrationId("shared-registration").getMemberId()); + assertEquals("", devices.selectByRegistrationId("shared-registration").getAppKey()); + + request.setAppKey(" " + TEST_APP_KEY + " "); + deviceService.bind(101L, request); + assertEquals(legacy.getId(), devices.selectByRegistrationId("shared-registration").getId()); + assertEquals(TEST_APP_KEY, devices.selectByRegistrationId("shared-registration").getAppKey()); + assertEquals(1, deviceService.getByMemberId(101L).size()); + TenantContextHolder.setTenantId(2L); + deviceService.bind(201L, request); + assertEquals(201L, devices.selectByRegistrationId("shared-registration").getMemberId()); + assertEquals(TEST_APP_KEY, devices.selectByRegistrationId("shared-registration").getAppKey()); + TenantContextHolder.setTenantId(1L); + deviceService.unbind(101L, "shared-registration"); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM member_push_device", Integer.class)); + TenantContextHolder.setTenantId(2L); + request.setAppKey(null); + deviceService.bind(201L, request); + assertEquals("", devices.selectByRegistrationId("shared-registration").getAppKey()); + assertTrue(deviceService.getByMemberId(201L).isEmpty()); + } + + @Test + void pendingBatchSkipsDeviceWhoseApplicationChangesAfterSnapshot() { + bind(101L, 1L, "registration-one"); + Long taskId = service.createManual(request("USER", 101L), 9L); + service.process(taskId); + bindForApp(101L, 1L, "registration-one", "old-app-key"); + service.process(taskId); + assertEquals("SKIPPED", service.get(taskId).getStatus()); + assertEquals("DEVICE_UNBOUND", onlyRecord(taskId).getErrorCode()); + verify(gateway, never()).send(anyString()); + } + + @Test + void uncertainBatchStopsAfterConfiguredApplicationChanges() { + bind(101L, 1L, "registration-one"); + Long taskId = service.createManual(request("USER", 101L), 9L); + service.process(taskId); + when(gateway.send(anyString())).thenThrow( + new JPushGateway.PushFailure("CONNECTION_UNCERTAIN", "response lost", true, true)); + service.process(taskId); + when(gateway.getAppKey()).thenReturn("replacement-app-key"); + makeDue(taskId); + service.process(taskId); + assertEquals("FAILED", service.get(taskId).getStatus()); + assertEquals("TARGET_CHANGED_UNCONFIRMED", onlyRecord(taskId).getErrorCode()); + assertEquals("cid-first", onlyRecord(taskId).getCid()); + verify(gateway, times(1)).send(anyString()); + } + + @Test + void uncertainBatchStopsWhenDeviceRebindsToAnotherApplication() { + bind(101L, 1L, "registration-one"); + Long taskId = service.createManual(request("USER", 101L), 9L); + service.process(taskId); + when(gateway.send(anyString())).thenThrow( + new JPushGateway.PushFailure("CONNECTION_UNCERTAIN", "response lost", true, true)); + service.process(taskId); + bindForApp(101L, 1L, "registration-one", "old-app-key"); + makeDue(taskId); + service.process(taskId); + assertEquals("FAILED", service.get(taskId).getStatus()); + assertEquals("TARGET_CHANGED_UNCONFIRMED", onlyRecord(taskId).getErrorCode()); + verify(gateway, times(1)).send(anyString()); + } + + @Test + void migrationAddsApplicationColumnAndIndexesWithoutRelabelingLegacyDevices() throws Exception { + jdbc.execute("DROP TABLE member_push_device"); + runTableDdl("member_push_device.sql"); + jdbc.update("INSERT INTO member_push_device(member_id,registration_id,platform,tenant_id) VALUES(101,'legacy-device','android',1)"); + runTableDdl("20261111_member_push_management.sql"); + runTableDdl("20261111_member_push_management.sql"); + assertEquals("", jdbc.queryForObject("SELECT app_key FROM member_push_device WHERE registration_id='legacy-device'", String.class)); + assertEquals(2, jdbc.queryForObject("SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS " + + "WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='member_push_device' " + + "AND INDEX_NAME IN ('idx_app_provider_id','idx_app_member_provider')", Integer.class)); + assertTrue(deviceService.getByMemberId(101L).isEmpty()); + } + + @Test + void concurrentBindingsCreateOneInstallationRow() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(6); + CountDownLatch start = new CountDownLatch(1); + List> calls = new ArrayList<>(); + try { + for (int index = 0; index < 6; index++) { + final long memberId = index % 2 == 0 ? 101L : 201L; + final long tenantId = index % 2 == 0 ? 1L : 2L; + calls.add(executor.submit(() -> { + TenantContextHolder.setTenantId(tenantId); + try { + start.await(); + bind(memberId, tenantId, "concurrent-registration"); + return null; + } finally { + TenantContextHolder.clear(); + } + })); + } + start.countDown(); + for (Future call : calls) call.get(10, TimeUnit.SECONDS); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM member_push_device", Integer.class)); + } finally { + executor.shutdownNow(); + } + } + + @Test + void pendingScanRotatesProcessedTasksBehindNewWork() { + bind(101L, 1L, "registration-one"); + List ids = new ArrayList<>(); + for (int index = 0; index < 21; index++) ids.add(service.createManual(request("ALL"), 9L)); + List first = tasks.selectPendingAcrossTenants(LocalDateTime.now().plusSeconds(1)); + assertEquals(20, first.size()); + assertFalse(first.stream().anyMatch(task -> task.getId().equals(ids.get(20)))); + first.forEach(task -> service.process(task.getId())); + assertTrue(tasks.selectPendingAcrossTenants(LocalDateTime.now().plusSeconds(1)).stream() + .anyMatch(task -> task.getId().equals(ids.get(20)))); + } + + private MemberPushTaskService newService() { + MemberPushTaskService result = new MemberPushTaskService(); + ReflectionTestUtils.setField(result, "taskMapper", tasks); + ReflectionTestUtils.setField(result, "recordMapper", records); + ReflectionTestUtils.setField(result, "deviceMapper", devices); + ReflectionTestUtils.setField(result, "userMapper", users); + ReflectionTestUtils.setField(result, "chatApi", chat); + ReflectionTestUtils.setField(result, "gateway", gateway); + ReflectionTestUtils.setField(result, "redisson", mock(RedissonClient.class)); + ReflectionTestUtils.setField(result, "transactions", new TransactionTemplate(transactionManager)); + return result; + } + + private void runTableDdl(String file) throws Exception { + String ddl = new String(Files.readAllBytes(Paths.get("../../sql/mysql/member", file)), StandardCharsets.UTF_8); + int menuStart = ddl.indexOf("-- 动态菜单"); + if (menuStart >= 0) ddl = ddl.substring(0, menuStart); + final String statements = ddl; + jdbc.execute((ConnectionCallback) connection -> { + try (Statement statement = connection.createStatement()) { + for (String sql : statements.split(";")) { + if (!sql.trim().isEmpty()) statement.execute(sql); + } + } + return null; + }); + } + + private void createMemberTable() { + List columns = new ArrayList<>(); + columns.add("id BIGINT PRIMARY KEY"); + for (TableFieldInfo field : TableInfoHelper.getTableInfo(MemberUserDO.class).getFieldList()) { + String type = "TEXT"; + if (field.getPropertyType() == LocalDateTime.class) type = "DATETIME(3)"; + else if (field.getPropertyType() == Long.class) type = "BIGINT"; + else if (field.getPropertyType() == Integer.class) type = "INT"; + else if (field.getPropertyType() == Boolean.class) type = "BIT(1)"; + if ("deleted".equals(field.getColumn())) type += " DEFAULT 0"; + columns.add("`" + field.getColumn() + "` " + type); + } + jdbc.execute("CREATE TABLE member_user (" + String.join(",", columns) + ")"); + } + + private void insertMember(Long id, Long tenantId) { + jdbc.update("INSERT INTO member_user(id,tenant_id,nickname,user_code,status) VALUES(?,?,?,?,0)", + id, tenantId, "会员" + id, "U" + id); + } + + private void insertDevices(Long memberId, Long tenantId, int count) { + List batch = new ArrayList<>(); + for (int index = 0; index < count; index++) { + batch.add(new Object[]{memberId, "registration-" + tenantId + "-" + index, "android", TEST_APP_KEY, tenantId}); + } + jdbc.batchUpdate("INSERT INTO member_push_device(member_id,registration_id,platform,app_key,tenant_id) VALUES(?,?,?,?,?)", batch); + } + + private void bind(Long memberId, Long tenantId, String registrationId) { + bindForApp(memberId, tenantId, registrationId, TEST_APP_KEY); + } + + private void bindForApp(Long memberId, Long tenantId, String registrationId, String appKey) { + MemberPushDeviceDO device = MemberPushDeviceDO.builder().memberId(memberId).registrationId(registrationId) + .alias("u" + memberId).platform("android").provider("jpush").appKey(appKey).build(); + device.setTenantId(tenantId); + devices.bindDevice(device); + } + + private MemberPushSendReqVO request(String type, Long... memberIds) { + MemberPushSendReqVO request = new MemberPushSendReqVO(); + request.setRequestId(UUID.randomUUID().toString()); + request.setTargetType(type); + request.setMemberIds(Arrays.asList(memberIds)); + request.setTitle("通知标题"); + request.setContent("通知内容"); + return request; + } + + private MemberPushRecordDO onlyRecord(Long taskId) { + return records.selectOne(MemberPushRecordDO::getTaskId, taskId); + } + + private void makeDue(Long taskId) { + jdbc.update("UPDATE member_push_task SET next_retry_time=DATE_SUB(NOW(3),INTERVAL 1 SECOND) WHERE id=?", taskId); + jdbc.update("UPDATE member_push_record SET next_retry_time=DATE_SUB(NOW(3),INTERVAL 1 SECOND) WHERE task_id=?", taskId); + } + + private ChatPrivatePushContextRespVO foreground(String session, Long version) { + ChatPrivatePushContextRespVO result = new ChatPrivatePushContextRespVO(); + ChatPrivatePushContextRespVO.ForegroundDevice device = new ChatPrivatePushContextRespVO.ForegroundDevice(); + device.setRegistrationId("registration-one"); + device.setSessionToken(session); + device.setHeartbeatVersion(version); + result.setForegroundDevices(Collections.singletonList(device)); + return result; + } + + private ImPrivatePushEvent privateEvent(Long senderId, Long receiverId) { + ImPrivatePushEvent event = new ImPrivatePushEvent(); + event.setEventId(UUID.randomUUID().toString()); + event.setMessageId(99001L); + event.setSendId(senderId); + event.setRecvId(receiverId); + event.setType(0); + event.setContent("私聊消息"); + event.setSendTime(System.currentTimeMillis()); + return event; + } + + private static class CommitFailureTransactionManager extends DataSourceTransactionManager { + private boolean failNextCommit; + + CommitFailureTransactionManager(DataSource dataSource) { + super(dataSource); + } + + @Override + protected void doCommit(DefaultTransactionStatus status) { + if (failNextCommit) { + failNextCommit = false; + throw new TransactionSystemException("Injected commit failure for cursor rollback regression"); + } + super.doCommit(status); + } + } +} diff --git a/yudao-server/src/main/resources/application.yaml b/yudao-server/src/main/resources/application.yaml index 7ea06e5..1454f85 100644 --- a/yudao-server/src/main/resources/application.yaml +++ b/yudao-server/src/main/resources/application.yaml @@ -14,8 +14,9 @@ spring: servlet: # 文件上传相关配置项 multipart: - max-file-size: 16MB # 单个文件大小 - max-request-size: 32MB # 设置总上传的文件大小 + max-file-size: ${APP_UPLOAD_MAX_FILE_SIZE:512MB} # 版本包上限,专用接口再次校验 + max-request-size: ${APP_UPLOAD_MAX_REQUEST_SIZE:520MB} # 包含 multipart 头部 + file-size-threshold: 0 # 安装包直接落盘,避免常驻 Java 堆 # Jackson 配置项 jackson: @@ -368,8 +369,8 @@ yudao: jpush: enabled: ${JPUSH_ENABLED:true} - app-key: ${JPUSH_APP_KEY:d7773439aa8c579dc31a552f} - master-secret: ${JPUSH_MASTER_SECRET:a2ea6bb666a26ff1dd246ffa} + app-key: ${JPUSH_APP_KEY:447fcdbce3b2dcac3091c0de} + master-secret: ${JPUSH_MASTER_SECRET:87816671c33ac4910e41a09e} apns-production: ${JPUSH_APNS_PRODUCTION:true} debug: false @@ -386,4 +387,4 @@ sto: from_appkey: 'CAKWRKlMXSPewDL' from_code: 'CAKWRKlMXSPewDL' to_appkey: 'sto_oms' - to_code: 'sto_oms' \ No newline at end of file + to_code: 'sto_oms'