From f3acddd021eae1c140f406561dd2d5e30dbf22ad Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 12 Nov 2026 14:03:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=A2=9E=E5=8A=A0=E4=BC=AA=E8=A3=85?= =?UTF-8?q?=E6=96=B9=E4=BE=BFios=E4=B8=8A=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/mysql/app/20261112_tea_room_booking.sql | 121 +++++ .../20261112_brokerage_bind_management.sql | 46 ++ ...112_brokerage_recharge_reward_backfill.sql | 79 +++ .../trade/enums/ErrorCodeConstants.java | 5 + .../brokerage/BrokerageUserController.java | 47 +- .../user/BrokerageBindCandidatePageReqVO.java | 19 + .../vo/user/BrokerageBindLogRespVO.java | 12 + .../vo/user/BrokerageBindMemberRespVO.java | 13 + .../vo/user/BrokerageUserBindChangeReqVO.java | 21 + .../user/BrokerageUserBindContextRespVO.java | 14 + .../BrokerageUserClearBrokerageUserReqVO.java | 8 +- ...BrokerageUserUpdateBrokerageUserReqVO.java | 8 +- .../brokerage/BrokerageBindLogDO.java | 28 ++ .../brokerage/BrokerageRechargeRewardDO.java | 18 + .../brokerage/BrokerageRecordDO.java | 3 + .../dataobject/brokerage/BrokerageUserDO.java | 3 + .../brokerage/BrokerageBindLogMapper.java | 21 + .../BrokerageRechargeRewardMapper.java | 14 + .../BrokerageRelationGuardMapper.java | 13 + .../mysql/brokerage/BrokerageUserMapper.java | 13 + .../brokerage/BrokerageRecordService.java | 5 + .../brokerage/BrokerageRecordServiceImpl.java | 153 +++--- .../brokerage/BrokerageUserService.java | 20 +- .../brokerage/BrokerageUserServiceImpl.java | 293 ++++++++--- .../bo/BrokerageRelationSnapshot.java | 10 + .../handler/TradeBrokerageOrderHandler.java | 24 +- .../brokerage/BrokerageBindContractTest.java | 42 ++ .../BrokerageBindManagementMySqlTest.java | 472 ++++++++++++++++++ .../TradeBrokeragePaymentBoundaryTest.java | 94 ++++ .../resources/sql/brokerage_income_schema.sql | 1 + .../sql/brokerage_relationship_schema.sql | 1 + .../src/test/resources/sql/create_tables.sql | 2 + .../module/member/api/user/MemberUserApi.java | 5 + .../member/api/user/MemberUserApiImpl.java | 18 + .../vo/txmap/geocoder/TxMapGeocoderReqVO.java | 5 +- .../admin/chat/ChatPrivateMsgController.java | 44 ++ .../admin/location/vo/LocationSaveReqVO.java | 6 +- .../tea/MemberAccountClosureController.java | 19 + .../admin/tea/MemberAppSettingController.java | 19 + .../admin/tea/MemberTeaBookingController.java | 22 + .../admin/tea/MemberTeaRoomController.java | 27 + .../app/tea/AppAccountClosureController.java | 20 + .../app/tea/AppTeaBookingController.java | 25 + .../app/tea/AppTeaPublicController.java | 34 ++ .../tea/vo/AccountClosureModels.java | 35 ++ .../member/controller/tea/vo/TeaModels.java | 129 +++++ .../tea/MemberAccountClosureDO.java | 18 + .../dataobject/tea/MemberAppSettingDO.java | 17 + .../dataobject/tea/MemberTeaBookingDO.java | 37 ++ .../dal/dataobject/tea/MemberTeaRoomDO.java | 24 + .../mysql/tea/MemberAccountClosureMapper.java | 9 + .../dal/mysql/tea/MemberAppSettingMapper.java | 10 + .../dal/mysql/tea/MemberTeaBookingMapper.java | 11 + .../dal/mysql/tea/MemberTeaRoomMapper.java | 11 + .../service/location/LocationServiceImpl.java | 7 +- .../service/opentype/OpenTypeServiceImpl.java | 4 + .../service/push/MemberPushTaskService.java | 37 +- .../service/tea/AccountClosureService.java | 72 +++ .../member/service/tea/TeaRoomService.java | 364 ++++++++++++++ .../api/user/MemberUserCandidateApiTest.java | 34 ++ ...tPrivateMessageModerationSecurityTest.java | 2 + .../chat/ChatPrivateMsgControllerTest.java | 99 +++- .../location/LocationServiceImplTest.java | 97 ++++ .../opentype/OpenTypeUserBrokerageTest.java | 9 + .../service/push/MemberPushModeTest.java | 91 ++++ .../push/MemberPushTaskServiceMySqlTest.java | 5 + .../tea/AccountClosureServiceTest.java | 47 ++ .../tea/TeaControllerSecurityTest.java | 113 +++++ .../service/tea/TeaRoomConcurrencyTest.java | 168 +++++++ .../service/tea/TeaRoomServiceTest.java | 156 ++++++ 70 files changed, 3282 insertions(+), 191 deletions(-) create mode 100644 sql/mysql/app/20261112_tea_room_booking.sql create mode 100644 sql/mysql/trade/20261112_brokerage_bind_management.sql create mode 100644 sql/mysql/trade/20261112_brokerage_recharge_reward_backfill.sql create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageBindCandidatePageReqVO.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageBindLogRespVO.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageBindMemberRespVO.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserBindChangeReqVO.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserBindContextRespVO.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/brokerage/BrokerageBindLogDO.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/brokerage/BrokerageRechargeRewardDO.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageBindLogMapper.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageRechargeRewardMapper.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageRelationGuardMapper.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/bo/BrokerageRelationSnapshot.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/BrokerageBindContractTest.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageBindManagementMySqlTest.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/order/handler/TradeBrokeragePaymentBoundaryTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/tea/MemberAccountClosureController.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/tea/MemberAppSettingController.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/tea/MemberTeaBookingController.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/tea/MemberTeaRoomController.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/tea/AppAccountClosureController.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/tea/AppTeaBookingController.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/tea/AppTeaPublicController.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/tea/vo/AccountClosureModels.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/tea/vo/TeaModels.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/tea/MemberAccountClosureDO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/tea/MemberAppSettingDO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/tea/MemberTeaBookingDO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/tea/MemberTeaRoomDO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/tea/MemberAccountClosureMapper.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/tea/MemberAppSettingMapper.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/tea/MemberTeaBookingMapper.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/tea/MemberTeaRoomMapper.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/tea/AccountClosureService.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/tea/TeaRoomService.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/api/user/MemberUserCandidateApiTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/location/LocationServiceImplTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/push/MemberPushModeTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/tea/AccountClosureServiceTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/tea/TeaControllerSecurityTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/tea/TeaRoomConcurrencyTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/tea/TeaRoomServiceTest.java diff --git a/sql/mysql/app/20261112_tea_room_booking.sql b/sql/mysql/app/20261112_tea_room_booking.sql new file mode 100644 index 0000000..59ce45a --- /dev/null +++ b/sql/mysql/app/20261112_tea_room_booking.sql @@ -0,0 +1,121 @@ +-- 单店茶室预约、分端 App 类型及人工注销申请。MySQL 5.7 / 8.0。 +-- 仅新增表和设置页菜单权限,不更改既有聊天数据,不填入虚构门店、包间或预约。 +-- 部署顺序:确认目标库并执行本脚本 -> Java -> 授予设置页权限 -> PC -> 重新打包 App。 +-- 读取未配置设置时默认三端均为聊天。所有配置与记录通过既有租户组件访问。 + +CREATE TABLE IF NOT EXISTS member_app_setting ( + id bigint NOT NULL AUTO_INCREMENT, + config_key varchar(32) NOT NULL DEFAULT 'main', + config_json mediumtext 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, + PRIMARY KEY (id), + UNIQUE KEY uk_app_setting (tenant_id, config_key, deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='App模式和单店设置'; + +CREATE TABLE IF NOT EXISTS member_tea_room ( + id bigint NOT NULL AUTO_INCREMENT, + name varchar(80) NOT NULL, + cover varchar(2048) NOT NULL, + capacity int NOT NULL, + facilities text NOT NULL COMMENT 'JSON字符串数组', + description varchar(2000) NOT NULL DEFAULT '', + price int NOT NULL COMMENT '每个预约时段价格,单位分,到店付款', + sort int NOT NULL DEFAULT 0, + enabled bit(1) NOT NULL DEFAULT b'1', + 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_tea_room_list (tenant_id, enabled, sort) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='茶室包间'; + +CREATE TABLE IF NOT EXISTS member_tea_booking ( + id bigint NOT NULL AUTO_INCREMENT, + booking_no varchar(40) NOT NULL, + member_id bigint NOT NULL, + room_id bigint NOT NULL, + room_name varchar(80) NOT NULL, + room_cover varchar(2048) NOT NULL, + store_name varchar(80) NOT NULL, + store_address varchar(300) NOT NULL, + store_phone varchar(30) NOT NULL, + date date NOT NULL COMMENT '中国标准时间预约日期', + start_time varchar(5) NOT NULL, + end_time varchar(5) NOT NULL, + start_at datetime NOT NULL, + end_at datetime NOT NULL, + persons int NOT NULL, + contact_name varchar(40) NOT NULL, + contact_phone varchar(30) NOT NULL, + remark varchar(500) NOT NULL DEFAULT '', + price int NOT NULL COMMENT '提交时价格快照,单位分', + status varchar(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/confirmed/completed/cancelled/rejected;过时pending对外计算expired', + reason varchar(500) NOT NULL DEFAULT '', + request_id varchar(64) DEFAULT NULL, + request_hash char(64) NOT NULL, + 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), + UNIQUE KEY uk_tea_booking_no (tenant_id, booking_no), + UNIQUE KEY uk_tea_booking_request (tenant_id, member_id, request_id), + KEY idx_tea_booking_room_time (tenant_id, room_id, status, start_at, end_at), + KEY idx_tea_booking_member (tenant_id, member_id, id), + KEY idx_tea_booking_admin (tenant_id, date, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='茶室预约申请与确认'; + +CREATE TABLE IF NOT EXISTS member_account_closure_request ( + id bigint NOT NULL AUTO_INCREMENT, + member_id bigint NOT NULL, + nickname varchar(80) DEFAULT '', + contact_phone varchar(30) DEFAULT '', + reason varchar(500) NOT NULL DEFAULT '', + status varchar(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/processing/resolved/rejected;resolved仅表示申请已人工处理', + process_remark varchar(1000) NOT NULL DEFAULT '', + 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_flag tinyint GENERATED ALWAYS AS (CASE WHEN deleted = 0 AND status IN ('pending','processing') THEN 1 ELSE NULL END) STORED, + PRIMARY KEY (id), + UNIQUE KEY uk_account_closure_active (tenant_id, member_id, active_flag), + KEY idx_account_closure_page (tenant_id, status, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='人工注销账号申请,不自动删除账号'; + +-- 优先复用已配置的设置页面,保持既有角色关联。 +SET @tea_setting_menu_id = (SELECT MIN(id) FROM system_menu WHERE component='app/setting/index' AND deleted=0); +SET @tea_app_parent_id = (SELECT parent_id FROM system_menu WHERE id=@tea_setting_menu_id); +SET @tea_app_parent_id = COALESCE(@tea_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 @tea_app_parent_id IS NULL; +SET @tea_app_parent_id = COALESCE(@tea_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:setting:query',2,20,@tea_app_parent_id,'setting','ep:setting','app/setting/index','AppSetting',0,b'1',b'1',b'1','1','1',b'0' +WHERE @tea_setting_menu_id IS NULL; +SET @tea_setting_menu_id = (SELECT MIN(id) FROM system_menu WHERE component='app/setting/index' AND deleted=0); +UPDATE system_menu SET permission='app:setting:query',component_name='AppSetting',update_time=CURRENT_TIMESTAMP WHERE id=@tea_setting_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,@tea_setting_menu_id,'',0,b'1',b'1',b'1','1','1',b'0' +FROM ( + SELECT '设置查询' name,'app:setting:query' permission,1 sort + UNION ALL SELECT '设置与包间维护','app:setting:update',2 + UNION ALL SELECT '预约与注销申请处理','app:setting:booking',3 +) buttons +WHERE NOT EXISTS (SELECT 1 FROM system_menu m WHERE m.permission=buttons.permission AND m.type=3 AND m.deleted=0); + diff --git a/sql/mysql/trade/20261112_brokerage_bind_management.sql b/sql/mysql/trade/20261112_brokerage_bind_management.sql new file mode 100644 index 0000000..ae21e57 --- /dev/null +++ b/sql/mysql/trade/20261112_brokerage_bind_management.sql @@ -0,0 +1,46 @@ +-- 在停止旧版本绑定写入及支付回调后执行。先执行本文件,再核对并回填充值首奖。 +-- MySQL 5.7 / 8.0,数据库结构升级仅执行一次。新旧分佣实例不能同时运行。 +ALTER TABLE trade_brokerage_user ADD COLUMN bind_version BIGINT NOT NULL DEFAULT 0 COMMENT '分销关系版本'; +ALTER TABLE trade_brokerage_record ADD COLUMN biz_scene VARCHAR(32) NULL COMMENT '资金来源 ORDER/RECHARGE/MEMBERSHIP'; + +CREATE TABLE IF NOT EXISTS trade_brokerage_relation_guard ( + id BIGINT NOT NULL PRIMARY KEY +) ENGINE=InnoDB COMMENT='分销关系修改互斥锁'; +INSERT INTO trade_brokerage_relation_guard (id) VALUES (1) ON DUPLICATE KEY UPDATE id=id; + +CREATE TABLE IF NOT EXISTS trade_brokerage_bind_log ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + old_bind_user_id BIGINT NULL, + new_bind_user_id BIGINT NULL, + old_bind_user_time DATETIME NULL, + new_bind_user_time DATETIME NULL, + old_version BIGINT NOT NULL, + new_version BIGINT NOT NULL, + operator_id BIGINT NULL, + source VARCHAR(32) NOT NULL, + reason VARCHAR(500) NOT NULL, + request_id VARCHAR(64) NOT NULL, + 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, + UNIQUE KEY uk_tenant_request (tenant_id, request_id), + KEY idx_user_id (user_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销上级变更记录'; + +CREATE TABLE IF NOT EXISTS trade_brokerage_recharge_reward ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + source_user_id BIGINT NOT NULL, + recharge_id VARCHAR(64) NOT NULL, + evidence_record_id BIGINT NULL, + 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, + UNIQUE KEY uk_tenant_source (tenant_id, source_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='永久充值首奖发放凭据,退款或删流水不恢复资格'; diff --git a/sql/mysql/trade/20261112_brokerage_recharge_reward_backfill.sql b/sql/mysql/trade/20261112_brokerage_recharge_reward_backfill.sql new file mode 100644 index 0000000..adbf3f9 --- /dev/null +++ b/sql/mysql/trade/20261112_brokerage_recharge_reward_backfill.sql @@ -0,0 +1,79 @@ +-- 执行前暂停绑定写入和支付回调,并先运行 20261112_brokerage_bind_management.sql。 +-- 必须在同一连接明确设置运行中的租户开关(0=关闭,1=开启)。 +-- SET @brokerage_tenant_enabled = 0; +-- 默认只生成核对清单。确认清单后,在同一连接设置下面变量再执行本文件以回填。 +-- SET @apply_brokerage_reward_backfill = 1; +-- 存在未确认记录时回填会报错。需结合备份及原始凭据核对后形成专门的资格回填语句。 +-- 不修改金额、余额和历史收益人。已取消/已软删除的首奖同样占用资格。 + +DROP PROCEDURE IF EXISTS backfill_brokerage_recharge_reward; +DELIMITER $$ +CREATE PROCEDURE backfill_brokerage_recharge_reward() +SQL SECURITY INVOKER +BEGIN + DECLARE EXIT HANDLER FOR SQLEXCEPTION + BEGIN + ROLLBACK; + RESIGNAL; + END; + IF @brokerage_tenant_enabled IS NULL OR @brokerage_tenant_enabled NOT IN (0, 1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Set @brokerage_tenant_enabled to the actual runtime value (0 or 1)'; + END IF; + DROP TEMPORARY TABLE IF EXISTS brokerage_reward_review; + CREATE TEMPORARY TABLE brokerage_reward_review AS + SELECT b.id AS record_id, b.source_user_id, + CASE WHEN @brokerage_tenant_enabled = 1 THEN b.tenant_id ELSE 0 END AS target_tenant_id, + b.biz_id AS recharge_id, b.create_time, + CASE + WHEN b.biz_scene = 'MEMBERSHIP' THEN 'OTHER' + WHEN b.biz_scene = 'ORDER' THEN 'OTHER' + WHEN b.title REGEXP '^用户\\[.*\\]充值\\[[0-9]+(\\.[0-9]{1,2})?\\]元$' + AND b.source_user_id IS NOT NULL AND b.source_user_id > 0 + AND r.id IS NOT NULL AND w.user_id = b.source_user_id + AND CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(b.title, '充值[', -1), ']', 1) AS DECIMAL(18,2)) * 100 = r.pay_price + AND (@brokerage_tenant_enabled = 0 OR (b.tenant_id = r.tenant_id AND r.tenant_id = w.tenant_id)) + AND NOT (b.title LIKE '%成功购买%' AND oi.id IS NOT NULL AND oi.user_id = b.source_user_id) + THEN 'RECHARGE' + WHEN (b.title LIKE '%提现%' OR b.description LIKE '%提现%') AND COALESCE(b.biz_scene, '') <> 'RECHARGE' THEN 'OTHER' + WHEN b.title LIKE '%成功购买%' AND oi.id IS NOT NULL AND oi.user_id = b.source_user_id + AND b.title NOT LIKE '%充值[%' + AND (@brokerage_tenant_enabled = 0 OR b.tenant_id = oi.tenant_id) THEN 'OTHER' + ELSE 'REVIEW_REQUIRED' + END AS classification + FROM trade_brokerage_record b + LEFT JOIN pay_wallet_recharge r ON CAST(r.id AS CHAR) = b.biz_id + LEFT JOIN pay_wallet w ON w.id = r.wallet_id + LEFT JOIN trade_order_item oi ON CAST(oi.id AS CHAR) = b.biz_id + WHERE b.biz_type = 1 AND b.price > 0; + + SELECT classification, COUNT(*) AS record_count FROM brokerage_reward_review GROUP BY classification; + SELECT record_id, source_user_id, recharge_id, target_tenant_id + FROM brokerage_reward_review WHERE classification = 'REVIEW_REQUIRED' ORDER BY record_id; + + IF COALESCE(@apply_brokerage_reward_backfill, 0) = 1 THEN + IF EXISTS (SELECT 1 FROM brokerage_reward_review WHERE classification = 'REVIEW_REQUIRED') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Unresolved historical brokerage records. Backfill was not applied'; + END IF; + DROP TEMPORARY TABLE IF EXISTS brokerage_reward_first; + CREATE TEMPORARY TABLE brokerage_reward_first AS + SELECT target_tenant_id, source_user_id, MIN(record_id) AS first_record_id + FROM brokerage_reward_review WHERE classification = 'RECHARGE' + GROUP BY target_tenant_id, source_user_id; + START TRANSACTION; + INSERT INTO trade_brokerage_recharge_reward + (source_user_id, recharge_id, evidence_record_id, create_time, creator, tenant_id) + SELECT evidence.source_user_id, evidence.recharge_id, evidence.record_id, + COALESCE(evidence.create_time, CURRENT_TIMESTAMP), 'history-backfill', evidence.target_tenant_id + FROM brokerage_reward_review evidence + INNER JOIN brokerage_reward_first first_reward ON first_reward.first_record_id = evidence.record_id + ON DUPLICATE KEY UPDATE source_user_id = trade_brokerage_recharge_reward.source_user_id; + UPDATE trade_brokerage_record b + INNER JOIN brokerage_reward_review evidence ON evidence.record_id = b.id AND evidence.classification = 'RECHARGE' + SET b.biz_scene = 'RECHARGE' WHERE b.biz_scene IS NULL; + COMMIT; + SELECT COUNT(*) AS permanent_reward_count FROM trade_brokerage_recharge_reward; + END IF; +END$$ +DELIMITER ; +CALL backfill_brokerage_recharge_reward(); +DROP PROCEDURE backfill_brokerage_recharge_reward; diff --git a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/ErrorCodeConstants.java b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/ErrorCodeConstants.java index 6b86dbe..ac89544 100644 --- a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/ErrorCodeConstants.java +++ b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/ErrorCodeConstants.java @@ -101,6 +101,11 @@ public interface ErrorCodeConstants { ErrorCode BROKERAGE_BIND_LOOP = new ErrorCode(1_011_007_007, "下级不能绑定自己的上级"); ErrorCode BROKERAGE_USER_LEVEL_NOT_SUPPORT = new ErrorCode(1_011_007_008, "目前只支持 level 小于等于 2"); ErrorCode BROKERAGE_CREATE_USER_EXISTS = new ErrorCode(1_011_007_009, "分销用户已存在"); + ErrorCode BROKERAGE_BIND_CONFLICT = new ErrorCode(1_011_007_010, "分销关系已变更,请重新打开窗口确认"); + ErrorCode BROKERAGE_BIND_CHAIN_INVALID = new ErrorCode(1_011_007_011, "上级关系存在循环、断链或层级异常,请先核实关系"); + ErrorCode BROKERAGE_BIND_MEMBER_DISABLED = new ErrorCode(1_011_007_012, "该会员已禁用,不能设置为分销上级"); + ErrorCode BROKERAGE_BIND_REQUEST_CONFLICT = new ErrorCode(1_011_007_013, "操作请求标识已用于其他操作,请重新打开窗口"); + ErrorCode BROKERAGE_BIND_GUARD_MISSING = new ErrorCode(1_011_007_014, "分销关系保护尚未初始化,请联系管理员完成升级"); // ========== 分销提现 模块 1-011-008-000 ========== ErrorCode BROKERAGE_WITHDRAW_NOT_EXISTS = new ErrorCode(1_011_008_000, "佣金提现记录不存在"); diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/BrokerageUserController.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/BrokerageUserController.java index 0c1d532..6584b99 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/BrokerageUserController.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/BrokerageUserController.java @@ -2,6 +2,9 @@ package cn.iocoder.yudao.module.trade.controller.admin.brokerage; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageBindLogDO; 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.user.*; @@ -26,6 +29,7 @@ import javax.annotation.Resource; import javax.validation.Valid; import java.util.Map; import java.util.Set; +import java.util.HashSet; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; @@ -59,7 +63,7 @@ public class BrokerageUserController { @Operation(summary = "修改推广员") @PreAuthorize("@ss.hasPermission('trade:brokerage-user:update-bind-user')") public CommonResult updateBindUser(@Valid @RequestBody BrokerageUserUpdateBrokerageUserReqVO updateReqVO) { - brokerageUserService.updateBrokerageUserId(updateReqVO.getId(), updateReqVO.getBindUserId()); + brokerageUserService.updateBrokerageUserId(updateReqVO, updateReqVO.getBindUserId()); return success(true); } @@ -67,10 +71,49 @@ public class BrokerageUserController { @Operation(summary = "清除推广员") @PreAuthorize("@ss.hasPermission('trade:brokerage-user:clear-bind-user')") public CommonResult clearBindUser(@Valid @RequestBody BrokerageUserClearBrokerageUserReqVO updateReqVO) { - brokerageUserService.updateBrokerageUserId(updateReqVO.getId(), null); + brokerageUserService.updateBrokerageUserId(updateReqVO, null); return success(true); } + @GetMapping("/bind-context") + @Operation(summary = "获取分销上级操作快照") + @PreAuthorize("@ss.hasAnyPermissions('trade:brokerage-user:update-bind-user', 'trade:brokerage-user:clear-bind-user')") + public CommonResult getBindContext(@RequestParam("id") Long id) { + return success(brokerageUserService.getBindContext(id)); + } + + @GetMapping("/bind-candidate-page") + @Operation(summary = "搜索分销上级候选会员") + @PreAuthorize("@ss.hasPermission('trade:brokerage-user:update-bind-user')") + public CommonResult> getBindCandidatePage(@Valid BrokerageBindCandidatePageReqVO query) { + return success(BeanUtils.toBean(memberUserApi.searchUserPage(query.getSearchField(), query.getKeyword(), query), + BrokerageBindMemberRespVO.class)); + } + + @GetMapping("/bind-log-page") + @Operation(summary = "分页查看分销上级变更记录") + @PreAuthorize("@ss.hasAnyPermissions('trade:brokerage-user:update-bind-user', 'trade:brokerage-user:clear-bind-user')") + public CommonResult> getBindLogPage(@RequestParam("id") Long id, + @Valid PageParam page) { + PageResult logs = brokerageUserService.getBindLogPage(id, page); + Set ids = new HashSet<>(); + logs.getList().forEach(log -> { + if (log.getOldBindUserId() != null) { + ids.add(log.getOldBindUserId()); + } + if (log.getNewBindUserId() != null) { + ids.add(log.getNewBindUserId()); + } + }); + Map members = memberUserApi.getUserMap(ids); + PageResult result = BeanUtils.toBean(logs, BrokerageBindLogRespVO.class); + result.getList().forEach(log -> { + log.setOldMember(BeanUtils.toBean(members.get(log.getOldBindUserId()), BrokerageBindMemberRespVO.class)); + log.setNewMember(BeanUtils.toBean(members.get(log.getNewBindUserId()), BrokerageBindMemberRespVO.class)); + }); + return success(result); + } + @PutMapping("/update-brokerage-enable") @Operation(summary = "修改推广资格") @PreAuthorize("@ss.hasPermission('trade:brokerage-user:update-brokerage-enable')") diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageBindCandidatePageReqVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageBindCandidatePageReqVO.java new file mode 100644 index 0000000..5e51d3a --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageBindCandidatePageReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +@Data +@EqualsAndHashCode(callSuper = true) +public class BrokerageBindCandidatePageReqVO extends PageParam { + @NotBlank + @Pattern(regexp = "userCode|nickname|mobile") + private String searchField; + @NotBlank(message = "请输入搜索条件") + @Size(max = 64) + private String keyword; +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageBindLogRespVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageBindLogRespVO.java new file mode 100644 index 0000000..4d53d5b --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageBindLogRespVO.java @@ -0,0 +1,12 @@ +package cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user; + +import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageBindLogDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class BrokerageBindLogRespVO extends BrokerageBindLogDO { + private BrokerageBindMemberRespVO oldMember; + private BrokerageBindMemberRespVO newMember; +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageBindMemberRespVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageBindMemberRespVO.java new file mode 100644 index 0000000..4fc941f --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageBindMemberRespVO.java @@ -0,0 +1,13 @@ +package cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user; + +import lombok.Data; + +/** 关系确认及候选列表所需的会员身份信息。 */ +@Data +public class BrokerageBindMemberRespVO { + private Long id; + private String userCode; + private String nickname; + private String avatar; + private Integer status; +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserBindChangeReqVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserBindChangeReqVO.java new file mode 100644 index 0000000..0dc8028 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserBindChangeReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user; + +import lombok.Data; +import javax.validation.constraints.*; + +@Data +public class BrokerageUserBindChangeReqVO { + @NotNull(message = "会员编号不能为空") + @Positive + private Long id; + private Long expectedBindUserId; + @NotNull(message = "请重新打开窗口确认当前关系") + @PositiveOrZero + private Long expectedVersion; + @NotBlank(message = "请填写操作原因") + @Size(max = 500, message = "操作原因不能超过500字") + private String reason; + @NotBlank(message = "操作请求标识不能为空") + @Pattern(regexp = "[a-zA-Z0-9-]{16,64}", message = "操作请求标识格式不正确") + private String requestId; +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserBindContextRespVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserBindContextRespVO.java new file mode 100644 index 0000000..c6d9293 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserBindContextRespVO.java @@ -0,0 +1,14 @@ +package cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class BrokerageUserBindContextRespVO { + private BrokerageBindMemberRespVO user; + private BrokerageBindMemberRespVO bindUser; + private Long bindUserId; + private LocalDateTime bindUserTime; + private Long version; + private Integer bindMode; +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserClearBrokerageUserReqVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserClearBrokerageUserReqVO.java index 5a05c56..f5f49e2 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserClearBrokerageUserReqVO.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserClearBrokerageUserReqVO.java @@ -4,15 +4,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.ToString; -import javax.validation.constraints.NotNull; @Schema(description = "管理后台 - 分销用户 - 清除推广员 Request VO") @Data @ToString(callSuper = true) -public class BrokerageUserClearBrokerageUserReqVO { - - @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "20019") - @NotNull(message = "用户编号不能为空") - private Long id; - +public class BrokerageUserClearBrokerageUserReqVO extends BrokerageUserBindChangeReqVO { } diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserUpdateBrokerageUserReqVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserUpdateBrokerageUserReqVO.java index 56391c4..9c5b921 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserUpdateBrokerageUserReqVO.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserUpdateBrokerageUserReqVO.java @@ -5,18 +5,16 @@ import lombok.Data; import lombok.ToString; import javax.validation.constraints.NotNull; +import javax.validation.constraints.Positive; @Schema(description = "管理后台 - 分销用户 - 修改推广员 Request VO") @Data @ToString(callSuper = true) -public class BrokerageUserUpdateBrokerageUserReqVO { - - @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "20019") - @NotNull(message = "用户编号不能为空") - private Long id; +public class BrokerageUserUpdateBrokerageUserReqVO extends BrokerageUserBindChangeReqVO { @Schema(description = "推广员编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "4587") @NotNull(message = "推广员编号不能为空") + @Positive private Long bindUserId; } diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/brokerage/BrokerageBindLogDO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/brokerage/BrokerageBindLogDO.java new file mode 100644 index 0000000..cfd171d --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/brokerage/BrokerageBindLogDO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.trade.dal.dataobject.brokerage; + +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; + +import java.time.LocalDateTime; + +@TableName("trade_brokerage_bind_log") +@Data +@EqualsAndHashCode(callSuper = true) +public class BrokerageBindLogDO extends TenantBaseDO { + @TableId + private Long id; + private Long userId; + private Long oldBindUserId; + private Long newBindUserId; + private LocalDateTime oldBindUserTime; + private LocalDateTime newBindUserTime; + private Long oldVersion; + private Long newVersion; + private Long operatorId; + private String source; + private String reason; + private String requestId; +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/brokerage/BrokerageRechargeRewardDO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/brokerage/BrokerageRechargeRewardDO.java new file mode 100644 index 0000000..f65c440 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/brokerage/BrokerageRechargeRewardDO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.trade.dal.dataobject.brokerage; + +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; + +@TableName("trade_brokerage_recharge_reward") +@Data +@EqualsAndHashCode(callSuper = true) +public class BrokerageRechargeRewardDO extends TenantBaseDO { + @TableId + private Long id; + private Long sourceUserId; + private String rechargeId; + private Long evidenceRecordId; +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/brokerage/BrokerageRecordDO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/brokerage/BrokerageRecordDO.java index c819d72..8e1dedc 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/brokerage/BrokerageRecordDO.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/brokerage/BrokerageRecordDO.java @@ -47,6 +47,9 @@ public class BrokerageRecordDO extends BaseDO { */ private Integer bizType; + /** 资金来源命名空间,区分共用 ORDER 类型的充值和商城订单项。 */ + private String bizScene; + /** * 标题 */ diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/brokerage/BrokerageUserDO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/brokerage/BrokerageUserDO.java index 8d73858..3968cf7 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/brokerage/BrokerageUserDO.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/brokerage/BrokerageUserDO.java @@ -42,6 +42,9 @@ public class BrokerageUserDO extends BaseDO { */ private LocalDateTime bindUserTime; + /** 仅在关系实际变化时递增,防止陈旧页面覆盖及 A→B→A。 */ + private Long bindVersion; + /** * 是否有分销资格 */ diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageBindLogMapper.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageBindLogMapper.java new file mode 100644 index 0000000..d005014 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageBindLogMapper.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.trade.dal.mysql.brokerage; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +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.trade.dal.dataobject.brokerage.BrokerageBindLogDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface BrokerageBindLogMapper extends BaseMapperX { + default BrokerageBindLogDO selectByRequestId(String requestId) { + return selectOne(new LambdaQueryWrapperX() + .eq(BrokerageBindLogDO::getRequestId, requestId).last("FOR UPDATE")); + } + + default PageResult selectPage(Long userId, PageParam page) { + return selectPage(page, new LambdaQueryWrapperX() + .eq(BrokerageBindLogDO::getUserId, userId).orderByDesc(BrokerageBindLogDO::getId)); + } +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageRechargeRewardMapper.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageRechargeRewardMapper.java new file mode 100644 index 0000000..1d51d08 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageRechargeRewardMapper.java @@ -0,0 +1,14 @@ +package cn.iocoder.yudao.module.trade.dal.mysql.brokerage; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageRechargeRewardDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface BrokerageRechargeRewardMapper extends BaseMapperX { + // 资格是永久凭据,即使记录被软删除也不恢复首奖资格。 + @Select("SELECT * FROM trade_brokerage_recharge_reward WHERE source_user_id = #{userId} FOR UPDATE") + BrokerageRechargeRewardDO selectBySourceForUpdate(@Param("userId") Long userId); +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageRelationGuardMapper.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageRelationGuardMapper.java new file mode 100644 index 0000000..9882ba6 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageRelationGuardMapper.java @@ -0,0 +1,13 @@ +package cn.iocoder.yudao.module.trade.dal.mysql.brokerage; + +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +/** 单行基础设施锁。业务表仍由现有租户插件处理。 */ +@Mapper +public interface BrokerageRelationGuardMapper { + @InterceptorIgnore(tenantLine = "true") + @Select("SELECT id FROM trade_brokerage_relation_guard WHERE id = 1 FOR UPDATE") + Long lock(); +} 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 d00dbeb..401bc39 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 @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.trade.dal.mysql.brokerage; import cn.hutool.core.convert.Convert; +import cn.iocoder.yudao.module.trade.service.brokerage.bo.BrokerageRelationSnapshot; import cn.hutool.core.lang.Assert; import cn.hutool.core.map.MapUtil; import cn.iocoder.yudao.framework.common.pojo.PageParam; @@ -35,6 +36,18 @@ import java.util.stream.Collectors; @Mapper public interface BrokerageUserMapper extends BaseMapperX { + default BrokerageUserDO selectByIdForUpdate(Long id) { + return selectOne(new LambdaQueryWrapperX() + .eq(BrokerageUserDO::getId, id).last("FOR UPDATE")); + } + + @Select("SELECT first_user.id AS first_user_id, second_user.id AS second_user_id " + + "FROM trade_brokerage_user source_user " + + "LEFT JOIN trade_brokerage_user first_user ON first_user.id = source_user.bind_user_id AND first_user.deleted = FALSE " + + "LEFT JOIN trade_brokerage_user second_user ON second_user.id = first_user.bind_user_id AND second_user.deleted = FALSE " + + "WHERE source_user.id = #{userId} AND source_user.deleted = FALSE") + BrokerageRelationSnapshot selectRelationSnapshot(@Param("userId") Long userId); + default PageResult selectChildrenPage(Long bindUserId, PageParam pageParam) { return selectPage(pageParam, new LambdaQueryWrapperX() .eq(BrokerageUserDO::getBindUserId, bindUserId) diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordService.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordService.java index 8fc5e80..c14c77a 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordService.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordService.java @@ -61,6 +61,11 @@ public interface BrokerageRecordService { */ void addBrokerage(Long userId, BrokerageRecordBizTypeEnum bizType, @Valid List list); + /** 返回本次支付快照中的一级上级,供商城订单保存实际归属。 */ + Long addOrderBrokerage(Long userId, @Valid List list); + + void cancelOrderBrokerage(String bizId, Long sourceUserId); + /** * 添加朋友圈分销【多级分佣】 * 首次分销才有用 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 b292675..9f21667 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 @@ -23,6 +23,9 @@ import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokera 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.dal.dataobject.brokerage.BrokerageRechargeRewardDO; +import cn.iocoder.yudao.module.trade.dal.mysql.brokerage.BrokerageRechargeRewardMapper; +import cn.iocoder.yudao.module.trade.service.brokerage.bo.BrokerageRelationSnapshot; import cn.iocoder.yudao.module.trade.dal.dataobject.config.TradeConfigDO; import cn.iocoder.yudao.module.trade.dal.mysql.brokerage.BrokerageRecordMapper; import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum; @@ -32,6 +35,7 @@ import cn.iocoder.yudao.module.trade.service.brokerage.bo.UserBrokerageSummaryRe import cn.iocoder.yudao.module.trade.service.config.TradeConfigService; import com.baomidou.mybatisplus.core.metadata.IPage; 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; @@ -60,6 +64,8 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService { @Resource private BrokerageRecordMapper brokerageRecordMapper; @Resource + private BrokerageRechargeRewardMapper rechargeRewardMapper; + @Resource private TradeConfigService tradeConfigService; @Resource private BrokerageUserService brokerageUserService; @@ -121,83 +127,85 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService { @Override @Transactional(rollbackFor = Exception.class) public void addBrokerage(Long userId, BrokerageRecordBizTypeEnum bizType, List list) { - TradeConfigDO memberConfig = tradeConfigService.getTradeConfig(); - // 0 未启用分销功能 - - // 1.1 获得一级推广人(人人分销:只要绑定了上级即可分佣,不校验上级推广员开关) - BrokerageUserDO firstUser = brokerageUserService.getBindBrokerageUser(userId); - if (firstUser == null) { - return; - } - // 1.2 计算一级分佣 - addBrokerage(firstUser, list, memberConfig.getBrokerageFrozenDays(), memberConfig.getBrokerageFirstPercent(), - bizType, 1); - - // 2.1 获得二级推广员 - if (firstUser.getBindUserId() == null) { - return; - } - BrokerageUserDO secondUser = brokerageUserService.getBrokerageUser(firstUser.getBindUserId()); - if (secondUser == null) { - return; - } - // 2.2 计算二级分佣 - addBrokerage(secondUser, list, memberConfig.getBrokerageFrozenDays(), memberConfig.getBrokerageSecondPercent(), - bizType, 2); + addWithSnapshot(brokerageUserService.getRelationSnapshot(userId), tradeConfigService.getTradeConfig(), + bizType, list, bizType == BrokerageRecordBizTypeEnum.OPEN_TYPE ? "MEMBERSHIP" : "ORDER"); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long addOrderBrokerage(Long userId, List list) { + BrokerageRelationSnapshot snapshot = brokerageUserService.getRelationSnapshot(userId); + addWithSnapshot(snapshot, tradeConfigService.getTradeConfig(), BrokerageRecordBizTypeEnum.ORDER, list, "ORDER"); + return snapshot.getFirstUserId(); } - /** - * 添加朋友圈分销【多级分佣】 - * 首次分销才有用 - * - * @param userId 会员编号 - * @param bizType 业务类型 - * @param list 请求参数列表 - */ @Override @Transactional(rollbackFor = Exception.class) public void addRecentBrokerage(Long userId, BrokerageRecordBizTypeEnum bizType, List list) { - TradeConfigDO memberConfig = tradeConfigService.getTradeConfig(); - // 0 未启用分销功能 - if (memberConfig == null || !BooleanUtil.isTrue(memberConfig.getBrokerageEnabled())) { - log.error("[addRecentBrokerage][增加佣金失败:brokerageEnabled 未配置,userId({}) bizType({}) list({})", userId, bizType, list); + if (list.size() != 1 || bizType != BrokerageRecordBizTypeEnum.ORDER + || !Objects.equals(userId, list.get(0).getSourceUserId())) { + throw new IllegalArgumentException("充值首奖只接受同一来源会员的一笔充值单"); + } + TradeConfigDO config = tradeConfigService.getTradeConfig(); + if (config == null || !Boolean.TRUE.equals(config.getBrokerageEnabled())) { return; } + BrokerageRelationSnapshot snapshot = brokerageUserService.getRelationSnapshot(userId); + BrokerageAddReqBO item = list.get(0); + boolean firstReward = snapshot.getFirstUserId() != null + && calculatePrice(item.getBasePrice(), config.getBrokerageFirstPercent(), item.getFirstFixedPrice()) > 0; + boolean secondReward = snapshot.getSecondUserId() != null + && calculatePrice(item.getBasePrice(), config.getBrokerageSecondPercent(), item.getSecondFixedPrice()) > 0; + if (!firstReward && !secondReward) { + return; + } + BrokerageRechargeRewardDO reward = new BrokerageRechargeRewardDO(); + reward.setSourceUserId(userId); + reward.setRechargeId(item.getBizId()); + try { + // 唯一约束等待并发事务提交,失败者只跳过首奖,充值本身仍正常入账。 + rechargeRewardMapper.insert(reward); + } catch (DuplicateKeyException duplicate) { + if (rechargeRewardMapper.selectBySourceForUpdate(userId) == null) { + throw duplicate; + } + return; + } + addWithSnapshot(snapshot, config, bizType, list, "RECHARGE"); + } - // 1.1 获得一级推广人(人人分销:只要绑定了上级即可分佣,不校验上级推广员开关) - BrokerageUserDO firstUser = brokerageUserService.getBindBrokerageUser(userId); - if (firstUser == null) { + private void addWithSnapshot(BrokerageRelationSnapshot snapshot, TradeConfigDO config, + BrokerageRecordBizTypeEnum bizType, List list, String scene) { + if (snapshot.getFirstUserId() == null || config == null || CollUtil.isEmpty(list)) { return; } - // 1.1.1查看一级分销员是否已经分销过 - Long firstSuccessCount = brokerageRecordMapper.selectCount(new LambdaQueryWrapperX() - .eq(BrokerageRecordDO::getUserId, firstUser.getId()) - .eq(BrokerageRecordDO::getBizType, bizType.getType()) - .eq(BrokerageRecordDO::getSourceUserId, userId) - ); - if (firstSuccessCount <= 0) { - // 1.2 计算一级分佣 - addBrokerage(firstUser, list, memberConfig.getBrokerageFrozenDays(), memberConfig.getBrokerageFirstPercent(), bizType, 1); + // 先按固定顺序锁余额,随后使用最新账前余额生成流水快照。 + Map recipients = new TreeMap<>(); + recipients.put(snapshot.getFirstUserId(), null); + if (snapshot.getSecondUserId() != null) { + recipients.put(snapshot.getSecondUserId(), null); } + recipients.replaceAll((id, ignored) -> brokerageUserService.lockBrokerageUser(id)); + addBrokerage(recipients.get(snapshot.getFirstUserId()), list, config.getBrokerageFrozenDays(), + config.getBrokerageFirstPercent(), bizType, 1, scene); + if (snapshot.getSecondUserId() != null) { + addBrokerage(recipients.get(snapshot.getSecondUserId()), list, config.getBrokerageFrozenDays(), + config.getBrokerageSecondPercent(), bizType, 2, scene); + } + } - // 2.1 获得二级推广员 - if (firstUser.getBindUserId() == null) { - return; - } - BrokerageUserDO secondUser = brokerageUserService.getBrokerageUser(firstUser.getBindUserId()); - if (secondUser == null) { - return; - } - // 2.1.1查看二级分销员是否已经分销过 - Long secondSuccessCount = brokerageRecordMapper.selectCount(new LambdaQueryWrapperX() - .eq(BrokerageRecordDO::getUserId, secondUser.getId()) - .eq(BrokerageRecordDO::getBizType, bizType) - .eq(BrokerageRecordDO::getSourceUserId, userId) - ); - if (secondSuccessCount <= 0) { - // 2.2 计算二级分佣 - addBrokerage(secondUser, list, memberConfig.getBrokerageFrozenDays(), memberConfig.getBrokerageSecondPercent(), bizType, 2); - } + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelOrderBrokerage(String bizId, Long sourceUserId) { + // 充值和商城沿用同一历史业务类型,必须同时限制来源场景和来源会员。 + List records = brokerageRecordMapper.selectList(new LambdaQueryWrapperX() + .eq(BrokerageRecordDO::getBizType, BrokerageRecordBizTypeEnum.ORDER.getType()) + .eq(BrokerageRecordDO::getBizId, bizId) + .eq(BrokerageRecordDO::getSourceUserId, sourceUserId) + .and(query -> query.eq(BrokerageRecordDO::getBizScene, "ORDER") + .or(legacy -> legacy.isNull(BrokerageRecordDO::getBizScene) + .like(BrokerageRecordDO::getTitle, "成功购买")))); + cancelRecords(records); } @Override @@ -209,7 +217,14 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService { return; } + cancelRecords(records); + } + + private void cancelRecords(List records) { records.forEach(record -> { + if (BrokerageRecordStatusEnum.CANCEL.getStatus().equals(record.getStatus())) { + return; + } // 1. 更新佣金记录为已失效 BrokerageRecordDO updateObj = new BrokerageRecordDO().setStatus(BrokerageRecordStatusEnum.CANCEL.getStatus()); int updateRows = brokerageRecordMapper.updateByIdAndStatus(record.getId(), record.getStatus(), updateObj); @@ -258,7 +273,7 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService { * @param sourceUserLevel 来源用户等级 */ private void addBrokerage(BrokerageUserDO user, List list, Integer brokerageFrozenDays, - Integer brokeragePercent, BrokerageRecordBizTypeEnum bizType, Integer sourceUserLevel) { + Integer brokeragePercent, BrokerageRecordBizTypeEnum bizType, Integer sourceUserLevel, String scene) { // 1.1 处理冻结时间 LocalDateTime unfreezeTime = null; if (brokerageFrozenDays != null && brokerageFrozenDays > 0) { @@ -281,17 +296,17 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService { if (brokeragePrice <= 0) { continue; } - totalBrokerage += brokeragePrice; + totalBrokerage = Math.addExact(totalBrokerage, brokeragePrice); // 创建记录实体 records.add(BrokerageRecordConvert.INSTANCE.convert(user, bizType, item.getBizId(), brokerageFrozenDays, brokeragePrice, unfreezeTime, item.getTitle(), - item.getSourceUserId(), sourceUserLevel)); + item.getSourceUserId(), sourceUserLevel).setBizScene(scene)); } if (CollUtil.isEmpty(records)) { return; } // 1.3 保存佣金记录 - brokerageRecordMapper.insertBatch(records); + records.forEach(brokerageRecordMapper::insert); // 2. 更新用户佣金 if (brokerageFrozenDays != null && brokerageFrozenDays > 0) { // 更新用户冻结佣金 diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserService.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserService.java index f642023..165f995 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserService.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserService.java @@ -10,7 +10,10 @@ import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokera import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserRankByUserCountRespVO; import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserRankPageReqVO; import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageUserDO; - +import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user.BrokerageUserBindChangeReqVO; +import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user.BrokerageUserBindContextRespVO; +import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageBindLogDO; +import cn.iocoder.yudao.module.trade.service.brokerage.bo.BrokerageRelationSnapshot; import javax.validation.Valid; import javax.validation.constraints.NotNull; import java.util.Collection; @@ -46,10 +49,19 @@ public interface BrokerageUserService { /** * 修改推广员编号 * - * @param id 用户编号 - * @param bindUserId 推广员编号 + * @param request 包含当前关系版本、预期上级及操作原因 + * @param bindUserId 推广员编号,为空时解除 */ - void updateBrokerageUserId(Long id, Long bindUserId); + void updateBrokerageUserId(@Valid BrokerageUserBindChangeReqVO request, + Long bindUserId); + + BrokerageUserBindContextRespVO getBindContext(Long id); + + PageResult getBindLogPage(Long id, PageParam page); + + BrokerageRelationSnapshot getRelationSnapshot(Long id); + + BrokerageUserDO lockBrokerageUser(Long id); /** * 修改推广资格 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 f743e34..ec5b9b5 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 @@ -6,6 +6,7 @@ import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.ObjUtil; import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.pojo.PageParam; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; @@ -15,6 +16,13 @@ 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.admin.brokerage.vo.user.BrokerageUserCreateReqVO; +import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user.BrokerageUserBindChangeReqVO; +import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user.BrokerageUserBindContextRespVO; +import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user.BrokerageBindMemberRespVO; +import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageBindLogDO; +import cn.iocoder.yudao.module.trade.dal.mysql.brokerage.BrokerageBindLogMapper; +import cn.iocoder.yudao.module.trade.dal.mysql.brokerage.BrokerageRelationGuardMapper; +import cn.iocoder.yudao.module.trade.service.brokerage.bo.BrokerageRelationSnapshot; import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user.BrokerageUserPageReqVO; import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserChildSummaryPageReqVO; import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserChildSummaryRespVO; @@ -30,6 +38,7 @@ 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.config.TradeConfigService; import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; @@ -42,6 +51,7 @@ import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionU import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMapByFilter; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; /** * 分销用户 Service 实现类 @@ -55,6 +65,11 @@ public class BrokerageUserServiceImpl implements BrokerageUserService { @Resource private BrokerageUserMapper brokerageUserMapper; + @Resource + private BrokerageRelationGuardMapper relationGuardMapper; + @Resource + private BrokerageBindLogMapper bindLogMapper; + @Resource private TradeConfigService tradeConfigService; @@ -98,28 +113,133 @@ public class BrokerageUserServiceImpl implements BrokerageUserService { } @Override - public void updateBrokerageUserId(Long id, Long bindUserId) { - // 校验存在 - BrokerageUserDO brokerageUser = validateBrokerageUserExists(id); - // 绑定关系未发生变化 - if (Objects.equals(brokerageUser.getBindUserId(), bindUserId)) { + @Transactional(rollbackFor = Exception.class) + public void updateBrokerageUserId(BrokerageUserBindChangeReqVO request, Long bindUserId) { + lockRelations(); + BrokerageBindLogDO previous = bindLogMapper.selectByRequestId(request.getRequestId()); + if (previous != null) { + if (!Objects.equals(previous.getUserId(), request.getId()) + || !Objects.equals(previous.getOldBindUserId(), request.getExpectedBindUserId()) + || !Objects.equals(previous.getOldVersion(), request.getExpectedVersion()) + || !Objects.equals(previous.getNewBindUserId(), bindUserId) + || !Objects.equals(previous.getReason(), request.getReason().trim()) + || !Objects.equals(previous.getOperatorId(), getLoginUserId()) + || !"ADMIN".equals(previous.getSource())) { + throw exception(BROKERAGE_BIND_REQUEST_CONFLICT); + } return; } - - // 情况一:清除推广员 - if (bindUserId == null) { - // 清除推广员 - brokerageUserMapper.updateBindUserIdAndBindUserTimeToNull(id); + MemberUserRespDTO member = memberUserApi.getUser(request.getId()); + if (member == null) { + throw exception(BROKERAGE_USER_NOT_EXISTS); + } + BrokerageUserDO user = brokerageUserMapper.selectByIdForUpdate(request.getId()); + Long version = user == null ? 0L : Objects.requireNonNull(user.getBindVersion()); + Long oldBindUserId = user == null ? null : user.getBindUserId(); + if (!Objects.equals(version, request.getExpectedVersion()) + || !Objects.equals(oldBindUserId, request.getExpectedBindUserId())) { + throw exception(BROKERAGE_BIND_CONFLICT); + } + if (Objects.equals(oldBindUserId, bindUserId)) { return; } - - // 情况二:修改推广员 - validateCanBindUser(brokerageUser, bindUserId); - brokerageUserMapper.updateById(fillBindUserData(bindUserId, new BrokerageUserDO().setId(id))); + if (user == null) { + user = getOrCreateForUpdate(request.getId()); + if (user == null) { + throw exception(BROKERAGE_USER_NOT_EXISTS); + } + } + if (bindUserId != null) { + validateCanBindUser(user, bindUserId); + } + changeBinding(user, bindUserId, "ADMIN", getLoginUserId(), request.getReason().trim(), request.getRequestId()); } @Override + public BrokerageUserBindContextRespVO getBindContext(Long id) { + MemberUserRespDTO member = memberUserApi.getUser(id); + if (member == null) { + throw exception(BROKERAGE_USER_NOT_EXISTS); + } + BrokerageUserDO user = brokerageUserMapper.selectById(id); + BrokerageUserBindContextRespVO context = new BrokerageUserBindContextRespVO(); + context.setUser(BeanUtils.toBean(member, BrokerageBindMemberRespVO.class)); + context.setVersion(user == null ? 0L : user.getBindVersion()); + if (user != null) { + context.setBindUserId(user.getBindUserId()); + context.setBindUserTime(user.getBindUserTime()); + context.setBindUser(user.getBindUserId() == null ? null : BeanUtils.toBean( + memberUserApi.getUser(user.getBindUserId()), BrokerageBindMemberRespVO.class)); + } + TradeConfigDO config = tradeConfigService.getTradeConfig(); + context.setBindMode(config == null ? null : config.getBrokerageBindMode()); + return context; + } + + @Override + public PageResult getBindLogPage(Long id, PageParam page) { + return bindLogMapper.selectPage(id, page); + } + + @Override + public BrokerageRelationSnapshot getRelationSnapshot(Long id) { + BrokerageRelationSnapshot snapshot = brokerageUserMapper.selectRelationSnapshot(id); + if (snapshot == null) { + return new BrokerageRelationSnapshot(); + } + if (Objects.equals(id, snapshot.getFirstUserId()) || Objects.equals(id, snapshot.getSecondUserId()) + || (snapshot.getFirstUserId() != null && Objects.equals(snapshot.getFirstUserId(), snapshot.getSecondUserId()))) { + throw exception(BROKERAGE_BIND_CHAIN_INVALID); + } + return snapshot; + } + + @Override + public BrokerageUserDO lockBrokerageUser(Long id) { + BrokerageUserDO user = brokerageUserMapper.selectByIdForUpdate(id); + if (user == null) { + throw exception(BROKERAGE_USER_NOT_EXISTS); + } + return user; + } + + private void lockRelations() { + if (relationGuardMapper.lock() == null) { + throw exception(BROKERAGE_BIND_GUARD_MISSING); + } + } + + private void changeBinding(BrokerageUserDO user, Long bindUserId, String source, Long operatorId, + String reason, String requestId) { + LocalDateTime bindTime = bindUserId == null ? null : LocalDateTime.now(); + long oldVersion = user.getBindVersion(); + BrokerageBindLogDO log = new BrokerageBindLogDO(); + log.setUserId(user.getId()); + log.setOldBindUserId(user.getBindUserId()); + log.setNewBindUserId(bindUserId); + log.setOldBindUserTime(user.getBindUserTime()); + log.setNewBindUserTime(bindTime); + log.setOldVersion(oldVersion); + log.setNewVersion(oldVersion + 1); + log.setOperatorId(operatorId); + log.setSource(source); + log.setReason(reason); + log.setRequestId(requestId); + int updated = brokerageUserMapper.update(null, new LambdaUpdateWrapper() + .eq(BrokerageUserDO::getId, user.getId()) + .set(BrokerageUserDO::getBindUserId, bindUserId) + .set(BrokerageUserDO::getBindUserTime, bindTime) + .set(BrokerageUserDO::getBindVersion, oldVersion + 1)); + if (updated != 1) { + throw exception(BROKERAGE_BIND_CONFLICT); + } + bindLogMapper.insert(log); + } + + @Override + @Transactional(rollbackFor = Exception.class) public void updateBrokerageUserEnabled(Long id, Boolean enabled) { + lockRelations(); // 校验存在 validateBrokerageUserExists(id); if (BooleanUtil.isTrue(enabled)) { @@ -150,16 +270,29 @@ public class BrokerageUserServiceImpl implements BrokerageUserService { } @Override + @Transactional(rollbackFor = Exception.class) public BrokerageUserDO getOrCreateBrokerageUser(Long id) { - BrokerageUserDO brokerageUser = brokerageUserMapper.selectById(id); - // 特殊:人人分销的情况下,如果分销人为空则创建分销人 - if (brokerageUser == null && ObjUtil.equal(BrokerageEnabledConditionEnum.ALL.getCondition(), - tradeConfigService.getTradeConfig().getBrokerageEnabledCondition())) { - brokerageUser = new BrokerageUserDO().setId(id).setBrokerageEnabled(true).setBrokeragePrice(0) - .setBrokerageTime(LocalDateTime.now()).setFrozenPrice(0); - brokerageUserMapper.insert(brokerageUser); + BrokerageUserDO existing = brokerageUserMapper.selectById(id); + if (existing != null) { + return existing; } - return brokerageUser; + lockRelations(); + return getOrCreateForUpdate(id); + } + + private BrokerageUserDO getOrCreateForUpdate(Long id) { + BrokerageUserDO user = brokerageUserMapper.selectByIdForUpdate(id); + TradeConfigDO config = tradeConfigService.getTradeConfig(); + if (user == null && config != null && ObjUtil.equal(BrokerageEnabledConditionEnum.ALL.getCondition(), + config.getBrokerageEnabledCondition())) { + if (memberUserApi.getUser(id) == null) { + throw exception(BROKERAGE_USER_NOT_EXISTS); + } + user = new BrokerageUserDO().setId(id).setBrokerageEnabled(true).setBrokeragePrice(0) + .setBrokerageTime(LocalDateTime.now()).setFrozenPrice(0).setBindVersion(0L); + brokerageUserMapper.insert(user); + } + return user; } @Override @@ -197,59 +330,50 @@ public class BrokerageUserServiceImpl implements BrokerageUserService { } @Override + @Transactional(rollbackFor = Exception.class) public boolean bindBrokerageUser(Long userId, Long bindUserId) { - // 1. 获得分销用户 - boolean isNewBrokerageUser = false; - BrokerageUserDO brokerageUser = brokerageUserMapper.selectById(userId); - if (brokerageUser == null) { // 分销用户不存在的情况:1. 新注册;2. 旧数据;3. 分销功能关闭后又打开 - isNewBrokerageUser = true; - brokerageUser = new BrokerageUserDO().setId(userId).setBrokerageEnabled(false).setBrokeragePrice(0).setFrozenPrice(0); + lockRelations(); + BrokerageUserDO user = brokerageUserMapper.selectByIdForUpdate(userId); + boolean newUser = user == null; + if (newUser) { + user = new BrokerageUserDO().setId(userId).setBrokerageEnabled(true).setBrokerageTime(LocalDateTime.now()) + .setBrokeragePrice(0).setFrozenPrice(0).setBindVersion(0L); } - - // 2.1 校验是否能绑定用户 - boolean validated = isUserCanBind(brokerageUser); - if (!validated) { + if (!isUserCanBind(user)) { return false; } - // 2.3 校验能否绑定 - validateCanBindUser(brokerageUser, bindUserId); - // 2.3 绑定用户(人人分销:新用户默认具备分销资格) - if (isNewBrokerageUser) { - brokerageUser.setBrokerageEnabled(true).setBrokerageTime(LocalDateTime.now()); - brokerageUserMapper.insert(fillBindUserData(bindUserId, brokerageUser)); - } else { - brokerageUserMapper.updateById(fillBindUserData(bindUserId, new BrokerageUserDO().setId(userId))); + validateCanBindUser(user, bindUserId); + if (Objects.equals(user.getBindUserId(), bindUserId)) { + return true; } + if (newUser) { + brokerageUserMapper.insert(user); + } + changeBinding(user, bindUserId, "MEMBER", userId, "会员邀请绑定", UUID.randomUUID().toString()); return true; } @Override @Transactional(rollbackFor = Exception.class) - public Long createBrokerageUser(BrokerageUserCreateReqVO createReqVO) { - // 1.1 校验分销用户是否已存在 - BrokerageUserDO brokerageUser = brokerageUserMapper.selectById(createReqVO.getUserId()); - if (brokerageUser != null) { + public Long createBrokerageUser(BrokerageUserCreateReqVO request) { + lockRelations(); + if (brokerageUserMapper.selectByIdForUpdate(request.getUserId()) != null) { throw exception(BROKERAGE_CREATE_USER_EXISTS); } - // 1.2 校验是否能绑定用户 - brokerageUser = BeanUtils.toBean(createReqVO, BrokerageUserDO.class).setId(createReqVO.getUserId()) - .setBrokerageTime(LocalDateTime.now()); - validateCanBindUser(brokerageUser, createReqVO.getBindUserId()); - - // 2. 创建分销人 - brokerageUserMapper.insert(brokerageUser); - return brokerageUser.getId(); - } - - /** - * 补全绑定用户的字段 - * - * @param bindUserId 绑定的用户编号 - * @param brokerageUser update 对象 - * @return 补全后的 update 对象 - */ - private BrokerageUserDO fillBindUserData(Long bindUserId, BrokerageUserDO brokerageUser) { - return brokerageUser.setBindUserId(bindUserId).setBindUserTime(LocalDateTime.now()); + if (memberUserApi.getUser(request.getUserId()) == null) { + throw exception(BROKERAGE_USER_NOT_EXISTS); + } + BrokerageUserDO user = new BrokerageUserDO().setId(request.getUserId()).setBrokerageEnabled(true) + .setBrokerageTime(LocalDateTime.now()).setBrokeragePrice(0).setFrozenPrice(0).setBindVersion(0L); + if (request.getBindUserId() != null) { + validateCanBindUser(user, request.getBindUserId()); + } + brokerageUserMapper.insert(user); + if (request.getBindUserId() != null) { + changeBinding(user, request.getBindUserId(), "ADMIN_CREATE", getLoginUserId(), + "创建分销用户时设置上级", UUID.randomUUID().toString()); + } + return user.getId(); } @Override @@ -339,31 +463,34 @@ public class BrokerageUserServiceImpl implements BrokerageUserService { } private void validateCanBindUser(BrokerageUserDO user, Long bindUserId) { - // 1.1 校验推广人是否存在 - MemberUserRespDTO bindUserInfo = memberUserApi.getUser(bindUserId); - if (bindUserInfo == null) { - throw exception(BROKERAGE_USER_NOT_EXISTS); - } - // 1.2 校验要绑定的用户有无推广资格 - BrokerageUserDO bindUser = getOrCreateBrokerageUser(bindUserId); - if (bindUser == null || BooleanUtil.isFalse(bindUser.getBrokerageEnabled())) { - throw exception(BROKERAGE_BIND_USER_NOT_ENABLED); - } - - // 2. 校验绑定自己 if (Objects.equals(user.getId(), bindUserId)) { throw exception(BROKERAGE_BIND_SELF); } - - // 3. 下级不能绑定自己的上级 - for (int i = 0; i <= Short.MAX_VALUE; i++) { - if (Objects.equals(bindUser.getBindUserId(), user.getId())) { + MemberUserRespDTO member = memberUserApi.getUser(bindUserId); + if (member == null) { + throw exception(BROKERAGE_USER_NOT_EXISTS); + } + if (!CommonStatusEnum.ENABLE.getStatus().equals(member.getStatus())) { + throw exception(BROKERAGE_BIND_MEMBER_DISABLED); + } + BrokerageUserDO parent = getOrCreateForUpdate(bindUserId); + if (parent == null || !Boolean.TRUE.equals(parent.getBrokerageEnabled())) { + throw exception(BROKERAGE_BIND_USER_NOT_ENABLED); + } + Set visited = new HashSet<>(); + while (parent != null) { + if (Objects.equals(parent.getId(), user.getId())) { throw exception(BROKERAGE_BIND_LOOP); } - bindUser = getBrokerageUser(bindUser.getBindUserId()); - // 找到根节点,结束循环 - if (bindUser == null || bindUser.getBindUserId() == null) { - break; + if (!visited.add(parent.getId()) || visited.size() > Short.MAX_VALUE) { + throw exception(BROKERAGE_BIND_CHAIN_INVALID); + } + if (parent.getBindUserId() == null) { + return; + } + parent = brokerageUserMapper.selectByIdForUpdate(parent.getBindUserId()); + if (parent == null) { + throw exception(BROKERAGE_BIND_CHAIN_INVALID); } } } diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/bo/BrokerageRelationSnapshot.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/bo/BrokerageRelationSnapshot.java new file mode 100644 index 0000000..0d88971 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/bo/BrokerageRelationSnapshot.java @@ -0,0 +1,10 @@ +package cn.iocoder.yudao.module.trade.service.brokerage.bo; + +import lombok.Data; + +/** 同一条查询读取的支付分佣关系,入账期间不再查找上级。 */ +@Data +public class BrokerageRelationSnapshot { + private Long firstUserId; + private Long secondUserId; +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/order/handler/TradeBrokerageOrderHandler.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/order/handler/TradeBrokerageOrderHandler.java index f528148..4a7f68c 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/order/handler/TradeBrokerageOrderHandler.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/order/handler/TradeBrokerageOrderHandler.java @@ -12,6 +12,8 @@ import cn.iocoder.yudao.module.trade.convert.order.TradeOrderConvert; import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageUserDO; import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderDO; import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderItemDO; +import cn.iocoder.yudao.module.trade.dal.mysql.order.TradeOrderMapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum; import cn.iocoder.yudao.module.trade.service.brokerage.BrokerageRecordService; import cn.iocoder.yudao.module.trade.service.brokerage.BrokerageUserService; @@ -42,6 +44,8 @@ public class TradeBrokerageOrderHandler implements TradeOrderHandler { private BrokerageRecordService brokerageRecordService; @Resource private BrokerageUserService brokerageUserService; + @Resource + private TradeOrderMapper tradeOrderMapper; @Override public void beforeOrderCreate(TradeOrderDO order, List orderItems) { @@ -54,10 +58,10 @@ public class TradeBrokerageOrderHandler implements TradeOrderHandler { @Override public void afterPayOrder(TradeOrderDO order, List orderItems) { - if (order.getBrokerageUserId() == null) { - return; - } - addBrokerage(order.getUserId(), orderItems); + Long recipientId = addBrokerage(order.getUserId(), orderItems); + tradeOrderMapper.update(null, new LambdaUpdateWrapper() + .eq(TradeOrderDO::getId, order.getId()).set(TradeOrderDO::getBrokerageUserId, recipientId)); + order.setBrokerageUserId(recipientId); } @Override @@ -66,9 +70,6 @@ public class TradeBrokerageOrderHandler implements TradeOrderHandler { if (!order.getPayStatus()) { return; } - if (order.getBrokerageUserId() == null) { - return; - } // 售后的订单项,已经在 afterCancelOrderItem 回滚库存,所以这里不需要重复回滚 orderItems = filterOrderItemListByNoneAfterSale(orderItems); @@ -80,10 +81,7 @@ public class TradeBrokerageOrderHandler implements TradeOrderHandler { @Override public void afterCancelOrderItem(TradeOrderDO order, TradeOrderItemDO orderItem) { - if (order.getBrokerageUserId() == null) { - return; - } - brokerageRecordService.cancelBrokerage(BrokerageRecordBizTypeEnum.ORDER, String.valueOf(orderItem.getId())); + brokerageRecordService.cancelOrderBrokerage(String.valueOf(orderItem.getId()), order.getUserId()); } /** @@ -98,7 +96,7 @@ public class TradeBrokerageOrderHandler implements TradeOrderHandler { * @param userId 用户编号 * @param orderItems 订单项 */ - protected void addBrokerage(Long userId, List orderItems) { + protected Long addBrokerage(Long userId, List orderItems) { MemberUserRespDTO user = memberUserApi.getUser(userId); Assert.notNull(user); ProductSpuRespDTO spu = productSpuApi.getSpu(orderItems.get(0).getSpuId()); @@ -108,7 +106,7 @@ public class TradeBrokerageOrderHandler implements TradeOrderHandler { // 每一个订单项,都会去生成分销记录 List addList = convertList(orderItems, item -> TradeOrderConvert.INSTANCE.convert(user, item, spu, sku)); - brokerageRecordService.addBrokerage(userId, BrokerageRecordBizTypeEnum.ORDER, addList); + return brokerageRecordService.addOrderBrokerage(userId, addList); } } diff --git a/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/BrokerageBindContractTest.java b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/BrokerageBindContractTest.java new file mode 100644 index 0000000..9afca93 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/BrokerageBindContractTest.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.trade.controller.admin.brokerage; + +import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user.*; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import javax.validation.Validation; +import javax.validation.ValidatorFactory; +import java.util.Set; +import java.util.stream.Collectors; +import static org.junit.jupiter.api.Assertions.*; + +class BrokerageBindContractTest { + @Test + void legacyRequestsWithoutReasonVersionAndIdempotencyKeyCannotChangeMoneyRelations() { + try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) { + BrokerageUserUpdateBrokerageUserReqVO request = new BrokerageUserUpdateBrokerageUserReqVO().setBindUserId(20L); + request.setId(300L); + Set invalid = factory.getValidator().validate(request).stream() + .map(v -> v.getPropertyPath().toString()).collect(Collectors.toSet()); + assertTrue(invalid.contains("reason")); + assertTrue(invalid.contains("expectedVersion")); + assertTrue(invalid.contains("requestId")); + request.setReason("核实关系").setExpectedVersion(0L).setRequestId("00000000-0000-4000-8000-000000000001"); + assertTrue(factory.getValidator().validate(request).isEmpty()); + request.setReason(" "); + assertFalse(factory.getValidator().validate(request).isEmpty()); + } + } + + @Test + void updateAndClearRetainIndependentServerPermissions() throws Exception { + assertEquals("@ss.hasPermission('trade:brokerage-user:update-bind-user')", + BrokerageUserController.class.getMethod("updateBindUser", BrokerageUserUpdateBrokerageUserReqVO.class) + .getAnnotation(PreAuthorize.class).value()); + assertEquals("@ss.hasPermission('trade:brokerage-user:update-bind-user')", + BrokerageUserController.class.getMethod("getBindCandidatePage", BrokerageBindCandidatePageReqVO.class) + .getAnnotation(PreAuthorize.class).value()); + assertEquals("@ss.hasPermission('trade:brokerage-user:clear-bind-user')", + BrokerageUserController.class.getMethod("clearBindUser", BrokerageUserClearBrokerageUserReqVO.class) + .getAnnotation(PreAuthorize.class).value()); + } +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageBindManagementMySqlTest.java b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageBindManagementMySqlTest.java new file mode 100644 index 0000000..a41466b --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageBindManagementMySqlTest.java @@ -0,0 +1,472 @@ +package cn.iocoder.yudao.module.trade.service.brokerage; + +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.api.user.MemberUserApi; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; +import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user.*; +import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.*; +import cn.iocoder.yudao.module.trade.dal.dataobject.config.TradeConfigDO; +import cn.iocoder.yudao.module.trade.dal.mysql.brokerage.*; +import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageEnabledConditionEnum; +import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum; +import cn.iocoder.yudao.module.trade.service.brokerage.bo.BrokerageAddReqBO; +import cn.iocoder.yudao.module.trade.service.config.TradeConfigService; +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.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.*; +import org.mybatis.spring.SqlSessionTemplate; +import org.mybatis.spring.transaction.SpringManagedTransactionFactory; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +/** Runs only against an explicitly supplied disposable localhost MySQL, using a unique schema per test. */ +class BrokerageBindManagementMySqlTest { + private JdbcTemplate admin; + private JdbcTemplate jdbc; + private String database; + private BrokerageUserService users; + private BrokerageRecordServiceImpl money; + private BrokerageRecordMapper recordMapper; + private BrokerageBindLogMapper logMapper; + private BrokerageUserMapper userMapper; + private MemberUserApi memberApi; + private TradeConfigDO config; + private TransactionTemplate transactions; + + @BeforeEach + void setUp() throws Exception { + String url = System.getenv("BROKERAGE_TEST_MYSQL_URL"); + assumeTrue(url != null && !url.isEmpty(), "Set BROKERAGE_TEST_MYSQL_URL to run disposable MySQL tests"); + assertTrue(url.matches("jdbc:mysql://(localhost|127\\.0\\.0\\.1):[0-9]+/[a-zA-Z0-9_]+(?:\\?.*)?")); + String password = System.getenv().getOrDefault("BROKERAGE_TEST_MYSQL_PASSWORD", ""); + admin = new JdbcTemplate(new UnpooledDataSource("com.mysql.cj.jdbc.Driver", url, "root", password)); + database = "codex_brokerage_" + UUID.randomUUID().toString().replace("-", ""); + admin.execute("CREATE DATABASE " + database + " CHARACTER SET utf8mb4"); + int query = url.indexOf('?'); + String baseUrl = query < 0 ? url : url.substring(0, query); + String schemaUrl = baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1) + database + (query < 0 ? "" : url.substring(query)); + UnpooledDataSource dataSource = new UnpooledDataSource("com.mysql.cj.jdbc.Driver", schemaUrl, "root", password); + jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE trade_brokerage_user (id BIGINT PRIMARY KEY,bind_user_id BIGINT,bind_user_time DATETIME," + + "brokerage_enabled BIT DEFAULT 1,brokerage_time DATETIME,brokerage_price INT DEFAULT 0,frozen_price INT DEFAULT 0," + + "creator VARCHAR(64),updater VARCHAR(64),create_time DATETIME,update_time DATETIME,deleted BIT DEFAULT 0,tenant_id BIGINT DEFAULT 1)"); + jdbc.execute("CREATE TABLE trade_brokerage_record (id INT AUTO_INCREMENT PRIMARY KEY,user_id BIGINT,biz_id VARCHAR(64)," + + "biz_type INT,title VARCHAR(255),description VARCHAR(255),price INT,total_price INT,status INT,frozen_days INT," + + "unfreeze_time DATETIME,source_user_id BIGINT,source_user_level INT,creator VARCHAR(64),updater VARCHAR(64)," + + "create_time DATETIME,update_time DATETIME,deleted BIT DEFAULT 0,tenant_id BIGINT DEFAULT 1)"); + String ddl = new String(Files.readAllBytes(Paths.get("../../sql/mysql/trade/20261112_brokerage_bind_management.sql")), StandardCharsets.UTF_8); + for (String statement : ddl.split(";")) { + if (!statement.trim().isEmpty()) jdbc.execute(statement); + } + DataSourceTransactionManager manager = new DataSourceTransactionManager(dataSource); + transactions = new TransactionTemplate(manager); + transactions.setTimeout(10); + MybatisConfiguration configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.setEnvironment(new Environment("brokerage-test", new SpringManagedTransactionFactory(), dataSource)); + GlobalConfigUtils.setGlobalConfig(configuration, new GlobalConfig() + .setDbConfig(new GlobalConfig.DbConfig().setIdType(IdType.AUTO)) + .setMetaObjectHandler(new DefaultDBFieldHandler())); + TenantProperties tenant = new TenantProperties(); + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(tenant))); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); + configuration.addInterceptor(interceptor); + configuration.addMapper(BrokerageUserMapper.class); + configuration.addMapper(BrokerageRecordMapper.class); + configuration.addMapper(BrokerageBindLogMapper.class); + configuration.addMapper(BrokerageRelationGuardMapper.class); + configuration.addMapper(BrokerageRechargeRewardMapper.class); + SqlSessionTemplate sessions = new SqlSessionTemplate(new MybatisSqlSessionFactoryBuilder().build(configuration)); + userMapper = sessions.getMapper(BrokerageUserMapper.class); + recordMapper = sessions.getMapper(BrokerageRecordMapper.class); + logMapper = sessions.getMapper(BrokerageBindLogMapper.class); + memberApi = mock(MemberUserApi.class); + when(memberApi.getUser(anyLong())).thenAnswer(call -> new MemberUserRespDTO().setId(call.getArgument(0)) + .setStatus(0).setNickname("会员").setUserCode("999827").setCreateTime(LocalDateTime.now())); + config = new TradeConfigDO().setBrokerageEnabled(true).setBrokerageBindMode(3) + .setBrokerageEnabledCondition(BrokerageEnabledConditionEnum.ALL.getCondition()) + .setBrokerageFrozenDays(0).setBrokerageFirstPercent(10).setBrokerageSecondPercent(5); + TradeConfigService configService = mock(TradeConfigService.class); + when(configService.getTradeConfig()).thenAnswer(call -> config); + BrokerageUserServiceImpl userTarget = new BrokerageUserServiceImpl(); + ReflectionTestUtils.setField(userTarget, "brokerageUserMapper", userMapper); + ReflectionTestUtils.setField(userTarget, "relationGuardMapper", sessions.getMapper(BrokerageRelationGuardMapper.class)); + ReflectionTestUtils.setField(userTarget, "bindLogMapper", logMapper); + ReflectionTestUtils.setField(userTarget, "memberUserApi", memberApi); + ReflectionTestUtils.setField(userTarget, "tradeConfigService", configService); + users = proxy(userTarget, manager); + BrokerageRecordServiceImpl moneyTarget = new BrokerageRecordServiceImpl(); + ReflectionTestUtils.setField(moneyTarget, "brokerageRecordMapper", recordMapper); + ReflectionTestUtils.setField(moneyTarget, "rechargeRewardMapper", sessions.getMapper(BrokerageRechargeRewardMapper.class)); + ReflectionTestUtils.setField(moneyTarget, "brokerageUserService", users); + ReflectionTestUtils.setField(moneyTarget, "tradeConfigService", configService); + money = proxy(moneyTarget, manager); + TenantContextHolder.setTenantId(1L); + for (long id : new long[] {1, 2, 10, 20, 30, 40, 50}) { + jdbc.update("INSERT INTO trade_brokerage_user(id) VALUES (?)", id); + } + jdbc.update("UPDATE trade_brokerage_user SET bind_user_id=10 WHERE id=1"); + jdbc.update("UPDATE trade_brokerage_user SET bind_user_id=20 WHERE id=10"); + jdbc.update("UPDATE trade_brokerage_user SET bind_user_id=40 WHERE id=30"); + } + + @SuppressWarnings("unchecked") + private T proxy(T target, DataSourceTransactionManager manager) { + ProxyFactory factory = new ProxyFactory(target); + factory.setProxyTargetClass(true); + factory.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource())); + return (T) factory.getProxy(); + } + + @AfterEach + void tearDown() { + TenantContextHolder.clear(); + if (admin != null && database != null) admin.execute("DROP DATABASE " + database); + } + + private BrokerageUserBindChangeReqVO request(long id, Long parent) { + BrokerageUserBindContextRespVO context = users.getBindContext(id); + return new BrokerageUserBindChangeReqVO().setId(id).setExpectedBindUserId(context.getBindUserId()) + .setExpectedVersion(context.getVersion()).setReason("核实后调整").setRequestId(UUID.randomUUID().toString()); + } + + private List order(String id) { + return Collections.singletonList(new BrokerageAddReqBO().setBizId(id).setSourceUserId(1L) + .setBasePrice(10000).setFirstFixedPrice(0).setSecondFixedPrice(0).setTitle("会员成功购买商品")); + } + + @Test + void changeClearAuditAndReplayPreserveMoneyAndDescendants() { + jdbc.update("UPDATE trade_brokerage_user SET bind_user_id=1 WHERE id=2"); + jdbc.update("UPDATE trade_brokerage_user SET brokerage_price=20841,frozen_price=3510 WHERE id=1"); + BrokerageUserBindChangeReqVO request = request(1L, 30L); + users.updateBrokerageUserId(request, 30L); + users.updateBrokerageUserId(request, 30L); + assertEquals(1, count("trade_brokerage_bind_log")); + assertEquals(30L, users.getBrokerageUser(1L).getBindUserId()); + assertEquals(1L, users.getBrokerageUser(1L).getBindVersion()); + assertEquals(1L, users.getBrokerageUser(2L).getBindUserId()); + assertEquals(20841, users.getBrokerageUser(1L).getBrokeragePrice()); + assertEquals(3510, users.getBrokerageUser(1L).getFrozenPrice()); + assertThrows(RuntimeException.class, () -> users.updateBrokerageUserId(request(1L, 1L), 1L)); + assertThrows(RuntimeException.class, () -> users.updateBrokerageUserId(request(1L, 2L), 2L)); + users.updateBrokerageUserId(request(1L, null), null); + assertNull(users.getBrokerageUser(1L).getBindUserId()); + assertNull(users.getBrokerageUser(1L).getBindUserTime()); + assertEquals(1L, users.getBrokerageUser(2L).getBindUserId()); + assertEquals(2, count("trade_brokerage_bind_log")); + // Retried old successful request must not undo a newer change. + users.updateBrokerageUserId(request, 30L); + assertNull(users.getBrokerageUser(1L).getBindUserId()); + } + + @Test + void staleVersionAbaAndReusedRequestAreRejected() { + BrokerageUserBindChangeReqVO stale = request(1, 30L); + users.updateBrokerageUserId(request(1, 30L), 30L); + users.updateBrokerageUserId(request(1, 10L), 10L); + assertThrows(RuntimeException.class, () -> users.updateBrokerageUserId(stale, 30L)); + BrokerageUserBindChangeReqVO saved = request(1, 30L); + users.updateBrokerageUserId(saved, 30L); + saved.setReason("改变请求内容"); + assertThrows(RuntimeException.class, () -> users.updateBrokerageUserId(saved, 30L)); + assertEquals(3, count("trade_brokerage_bind_log")); + } + + @Test + void brokenCyclesDisabledAndTransactionalAuditFailureRejectWholeChange() { + jdbc.update("UPDATE trade_brokerage_user SET bind_user_id=40 WHERE id=30"); + jdbc.update("UPDATE trade_brokerage_user SET bind_user_id=30 WHERE id=40"); + assertThrows(RuntimeException.class, () -> users.updateBrokerageUserId(request(1, 30L), 30L)); + jdbc.update("UPDATE trade_brokerage_user SET bind_user_id=99999 WHERE id=40"); + assertThrows(RuntimeException.class, () -> users.updateBrokerageUserId(request(1, 30L), 30L)); + when(memberApi.getUser(50L)).thenReturn(new MemberUserRespDTO().setId(50L).setStatus(1)); + assertThrows(RuntimeException.class, () -> users.updateBrokerageUserId(request(1, 50L), 50L)); + jdbc.execute("CREATE TRIGGER reject_bind_log BEFORE INSERT ON trade_brokerage_bind_log FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='audit unavailable'"); + assertThrows(RuntimeException.class, () -> users.updateBrokerageUserId(request(1, 50L), 20L)); + assertEquals(10L, users.getBrokerageUser(1L).getBindUserId()); + assertEquals(0L, users.getBrokerageUser(1L).getBindVersion()); + assertEquals(0, count("trade_brokerage_bind_log")); + } + + @Test + void paidOrdersUseCurrentWholeChainAndRefundsKeepOriginalRecipients() { + config.setBrokerageFrozenDays(1); + money.addOrderBrokerage(1L, order("101")); + assertEquals(1000, users.getBrokerageUser(10L).getFrozenPrice()); + assertEquals(500, users.getBrokerageUser(20L).getFrozenPrice()); + users.updateBrokerageUserId(request(1, 30L), 30L); + assertEquals(1000, users.getBrokerageUser(10L).getFrozenPrice()); + assertEquals(30L, money.addOrderBrokerage(1L, order("102"))); + assertEquals(1000, users.getBrokerageUser(30L).getFrozenPrice()); + assertEquals(500, users.getBrokerageUser(40L).getFrozenPrice()); + BrokerageRecordDO first = recordMapper.selectById(1); + assertTrue(money.unfreezeRecord(first)); + assertEquals(1000, users.getBrokerageUser(10L).getBrokeragePrice()); + money.cancelOrderBrokerage("101", 1L); + money.cancelOrderBrokerage("101", 1L); + assertEquals(0, users.getBrokerageUser(10L).getBrokeragePrice()); + assertEquals(0, users.getBrokerageUser(20L).getFrozenPrice()); + users.updateBrokerageUserId(request(1, null), null); + assertNull(money.addOrderBrokerage(1L, order("103"))); + assertEquals(4, count("trade_brokerage_record")); + } + + @Test + void firstRechargeIsPermanentAndOrderRefundDoesNotCancelSameNumberRecharge() { + money.addRecentBrokerage(1L, BrokerageRecordBizTypeEnum.ORDER, order("201")); + users.updateBrokerageUserId(request(1, 30L), 30L); + money.addRecentBrokerage(1L, BrokerageRecordBizTypeEnum.ORDER, order("202")); + assertEquals(1, count("trade_brokerage_recharge_reward")); + assertEquals(2, count("trade_brokerage_record")); + money.cancelOrderBrokerage("201", 1L); + assertEquals(1000, users.getBrokerageUser(10L).getBrokeragePrice()); + jdbc.update("UPDATE trade_brokerage_record SET deleted=1,status=2"); + jdbc.update("UPDATE trade_brokerage_recharge_reward SET deleted=1"); + money.addRecentBrokerage(1L, BrokerageRecordBizTypeEnum.ORDER, order("203")); + assertEquals(2, count("trade_brokerage_record")); + assertEquals(0, users.getBrokerageUser(30L).getBrokeragePrice()); + } + + @Test + void zeroOrNoParentDoesNotConsumeFirstRechargeAndOrderIncomeDoesNotConsumeIt() { + money.addOrderBrokerage(1L, order("301")); + config.setBrokerageFirstPercent(0).setBrokerageSecondPercent(0); + money.addRecentBrokerage(1L, BrokerageRecordBizTypeEnum.ORDER, order("302")); + assertEquals(0, count("trade_brokerage_recharge_reward")); + config.setBrokerageFirstPercent(10).setBrokerageSecondPercent(5); + users.updateBrokerageUserId(request(1, null), null); + money.addRecentBrokerage(1L, BrokerageRecordBizTypeEnum.ORDER, order("303")); + assertEquals(0, count("trade_brokerage_recharge_reward")); + users.updateBrokerageUserId(request(1, 30L), 30L); + money.addRecentBrokerage(1L, BrokerageRecordBizTypeEnum.ORDER, order("304")); + assertEquals(1, count("trade_brokerage_recharge_reward")); + assertEquals(4, count("trade_brokerage_record")); + } + + @Test + void moneyFailureRollsBackQualificationRecordsAndBalances() { + jdbc.execute("CREATE TRIGGER reject_second_record BEFORE INSERT ON trade_brokerage_record FOR EACH ROW BEGIN IF NEW.source_user_level=2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='record unavailable'; END IF; END"); + assertThrows(RuntimeException.class, () -> money.addRecentBrokerage(1L, BrokerageRecordBizTypeEnum.ORDER, order("401"))); + assertEquals(0, count("trade_brokerage_recharge_reward")); + assertEquals(0, count("trade_brokerage_record")); + assertEquals(0, users.getBrokerageUser(10L).getBrokeragePrice()); + } + + @Test + void parallelAdminAndAppCannotCreateCycle() throws Exception { + jdbc.update("UPDATE trade_brokerage_user SET bind_user_id=NULL WHERE id IN (1,2)"); + BrokerageUserBindChangeReqVO request = request(1, 2L); + List results = parallel(() -> { users.updateBrokerageUserId(request, 2L); return true; }, + () -> users.bindBrokerageUser(2L, 1L)); + assertEquals(1, results.stream().filter(Boolean::booleanValue).count()); + assertFalse(Objects.equals(2L, users.getBrokerageUser(1L).getBindUserId()) + && Objects.equals(1L, users.getBrokerageUser(2L).getBindUserId())); + } + + @Test + void parallelAdministratorsCannotCreateCycle() throws Exception { + jdbc.update("UPDATE trade_brokerage_user SET bind_user_id=NULL WHERE id IN (1,2)"); + BrokerageUserBindChangeReqVO first = request(1, 2L); + BrokerageUserBindChangeReqVO second = request(2, 1L); + List results = parallel(() -> { users.updateBrokerageUserId(first, 2L); return true; }, + () -> { users.updateBrokerageUserId(second, 1L); return true; }); + assertEquals(1, results.stream().filter(Boolean::booleanValue).count()); + assertFalse(Objects.equals(2L, users.getBrokerageUser(1L).getBindUserId()) + && Objects.equals(1L, users.getBrokerageUser(2L).getBindUserId())); + } + + @Test + void parallelRechargesProduceOneWholeRewardGroup() throws Exception { + List results = parallel(() -> { money.addRecentBrokerage(1L, BrokerageRecordBizTypeEnum.ORDER, order("501")); return true; }, + () -> { money.addRecentBrokerage(1L, BrokerageRecordBizTypeEnum.ORDER, order("502")); return true; }); + assertEquals(Arrays.asList(true, true), results); + assertEquals(1, count("trade_brokerage_recharge_reward")); + assertEquals(2, count("trade_brokerage_record")); + assertEquals(1000, users.getBrokerageUser(10L).getBrokeragePrice()); + assertEquals(500, users.getBrokerageUser(20L).getBrokeragePrice()); + } + + @Test + void concurrentPaymentKeepsItsTransactionSnapshotThenLaterPaymentSeesNewChain() throws Exception { + ExecutorService executor = Executors.newSingleThreadExecutor(); + CountDownLatch snapshotReady = new CountDownLatch(1); + CountDownLatch changed = new CountDownLatch(1); + Future payment = executor.submit(() -> inTenant(() -> transactions.execute(status -> { + users.getBrokerageUser(1L); // establish this payment's repeatable-read snapshot + snapshotReady.countDown(); + await(changed); + return money.addOrderBrokerage(1L, order("601")); + }))); + try { + assertTrue(snapshotReady.await(5, TimeUnit.SECONDS)); + users.updateBrokerageUserId(request(1, 30L), 30L); + changed.countDown(); + assertEquals(10L, payment.get(10, TimeUnit.SECONDS)); + assertEquals(30L, money.addOrderBrokerage(1L, order("602"))); + assertEquals(1000, users.getBrokerageUser(10L).getBrokeragePrice()); + assertEquals(500, users.getBrokerageUser(20L).getBrokeragePrice()); + assertEquals(1000, users.getBrokerageUser(30L).getBrokeragePrice()); + assertEquals(500, users.getBrokerageUser(40L).getBrokeragePrice()); + } finally { + changed.countDown(); + executor.shutdownNow(); + } + } + + @Test + void historyBackfillIncludesCancelledDeletedRewardsSeparatesOrdersAndIsRepeatable() throws Exception { + prepareHistory(); + runBackfill(1, false); + assertEquals(0, count("trade_brokerage_recharge_reward")); + runBackfill(1, true); + runBackfill(1, true); + assertEquals(1, count("trade_brokerage_recharge_reward")); + assertEquals(1L, jdbc.queryForObject("SELECT evidence_record_id FROM trade_brokerage_recharge_reward", Long.class)); + assertEquals(3, count("trade_brokerage_record")); + users.updateBrokerageUserId(request(1, 30L), 30L); + money.addRecentBrokerage(1L, BrokerageRecordBizTypeEnum.ORDER, order("900")); + assertEquals(3, count("trade_brokerage_record")); + } + + @Test + void ambiguousHistoryStopsBackfillBeforeAnyMarkerIsWritten() throws Exception { + prepareHistory(); + jdbc.update("INSERT INTO trade_brokerage_record(id,user_id,source_user_id,biz_id,biz_type,title,price,status) VALUES (4,10,2,'999',1,'用户[甲]充值[100.00]元',1000,1)"); + assertThrows(RuntimeException.class, () -> runBackfill(1, true)); + assertEquals(0, count("trade_brokerage_recharge_reward")); + } + + @Test + void disabledTenantBackfillUsesSameDefaultTenantAsNewRuntimeMarkers() throws Exception { + prepareHistory(); + runBackfill(0, true); + assertEquals(0L, jdbc.queryForObject("SELECT tenant_id FROM trade_brokerage_recharge_reward", Long.class)); + TenantContextHolder.setIgnore(true); + money.addRecentBrokerage(1L, BrokerageRecordBizTypeEnum.ORDER, order("900")); + assertEquals(3, count("trade_brokerage_record")); + assertEquals(1, count("trade_brokerage_recharge_reward")); + } + + @Test + void absentBrokerageProfileIsCreatedTransactionallyAndInvalidSelectionRollsItBack() { + jdbc.update("DELETE FROM trade_brokerage_user WHERE id=50"); + assertThrows(RuntimeException.class, () -> users.updateBrokerageUserId(request(50, 50L), 50L)); + assertNull(users.getBrokerageUser(50L)); + users.updateBrokerageUserId(request(50, 30L), 30L); + assertEquals(30L, users.getBrokerageUser(50L).getBindUserId()); + assertEquals(0, users.getBrokerageUser(50L).getBrokeragePrice()); + users.createBrokerageUser(new BrokerageUserCreateReqVO().setUserId(60L).setBindUserId(50L)); + assertEquals(50L, users.getBrokerageUser(60L).getBindUserId()); + assertThrows(RuntimeException.class, () -> users.updateBrokerageUserId(request(50, 60L), 60L)); + } + + @Test + void unbindingKeepsConfiguredAppRebindingRulesAndDoesNotRestoreReward() { + money.addRecentBrokerage(1L, BrokerageRecordBizTypeEnum.ORDER, order("701")); + users.updateBrokerageUserId(request(1, null), null); + config.setBrokerageBindMode(1); + assertTrue(users.bindBrokerageUser(1L, 30L)); + money.addRecentBrokerage(1L, BrokerageRecordBizTypeEnum.ORDER, order("702")); + assertEquals(2, count("trade_brokerage_record")); + users.updateBrokerageUserId(request(1, null), null); + config.setBrokerageBindMode(2); + when(memberApi.getUser(1L)).thenReturn(new MemberUserRespDTO().setId(1L).setCreateTime(LocalDateTime.now().minusDays(1))); + assertThrows(RuntimeException.class, () -> users.bindBrokerageUser(1L, 30L)); + } + + @Test + void firstLevelOnlyRewardDoesNotFillNewSecondLevelAfterRebinding() { + jdbc.update("UPDATE trade_brokerage_user SET bind_user_id=NULL WHERE id=10"); + money.addRecentBrokerage(1L, BrokerageRecordBizTypeEnum.ORDER, order("801")); + users.updateBrokerageUserId(request(1, 30L), 30L); + money.addRecentBrokerage(1L, BrokerageRecordBizTypeEnum.ORDER, order("802")); + assertEquals(1, count("trade_brokerage_record")); + assertEquals(0, users.getBrokerageUser(40L).getBrokeragePrice()); + } + + private void prepareHistory() { + jdbc.execute("CREATE TABLE pay_wallet_recharge(id BIGINT PRIMARY KEY,wallet_id BIGINT,pay_price INT,tenant_id BIGINT DEFAULT 1)"); + jdbc.execute("CREATE TABLE pay_wallet(id BIGINT PRIMARY KEY,user_id BIGINT,tenant_id BIGINT DEFAULT 1)"); + jdbc.execute("CREATE TABLE trade_order_item(id BIGINT PRIMARY KEY,user_id BIGINT,tenant_id BIGINT DEFAULT 1)"); + jdbc.update("INSERT INTO pay_wallet VALUES(1,1,1)"); + jdbc.update("INSERT INTO pay_wallet_recharge VALUES(1,1,10000,1)"); + jdbc.update("INSERT INTO trade_order_item VALUES(1,1,1),(3,2,1)"); + jdbc.update("INSERT INTO trade_brokerage_record(id,user_id,source_user_id,biz_id,biz_type,title,price,status,deleted,tenant_id) VALUES " + + "(1,10,1,'1',1,'用户[甲]充值[100.00]元',1000,2,1,1)," + + "(2,20,1,'1',1,'用户[甲]充值[100.00]元',500,1,0,1)," + + "(3,30,2,'3',1,'乙成功购买商品',1000,1,0,1)"); + } + + private void runBackfill(int tenantEnabled, boolean apply) throws Exception { + String script = new String(Files.readAllBytes(Paths.get("../../sql/mysql/trade/20261112_brokerage_recharge_reward_backfill.sql")), StandardCharsets.UTF_8); + String procedure = script.substring(script.indexOf("CREATE PROCEDURE"), script.indexOf("END$$") + 3); + // Session variables, temporary review table and procedure call must use one physical connection. + jdbc.execute((org.springframework.jdbc.core.ConnectionCallback) connection -> { + try (java.sql.Statement statement = connection.createStatement()) { + statement.execute("SET @brokerage_tenant_enabled=" + tenantEnabled); + statement.execute("SET @apply_brokerage_reward_backfill=" + (apply ? 1 : 0)); + statement.execute("DROP PROCEDURE IF EXISTS backfill_brokerage_recharge_reward"); + statement.execute(procedure); + statement.execute("CALL backfill_brokerage_recharge_reward()"); + while (statement.getMoreResults() || statement.getUpdateCount() != -1) { /* consume review result sets */ } + statement.execute("DROP PROCEDURE backfill_brokerage_recharge_reward"); + } + return null; + }); + } + + private int count(String table) { return jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Integer.class); } + private T inTenant(Callable task) throws Exception { + TenantContextHolder.setTenantId(1L); + try { return task.call(); } finally { TenantContextHolder.clear(); } + } + private List parallel(Callable first, Callable second) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + try { + List> jobs = new ArrayList<>(); + for (Callable task : Arrays.asList(first, second)) { + jobs.add(executor.submit(() -> inTenant(() -> { + await(start); + try { return task.call(); } catch (RuntimeException rejected) { return false; } + }))); + } + start.countDown(); + return Arrays.asList(jobs.get(0).get(10, TimeUnit.SECONDS), jobs.get(1).get(10, TimeUnit.SECONDS)); + } finally { executor.shutdownNow(); } + } + private static void await(CountDownLatch latch) { + try { assertTrue(latch.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IllegalStateException(interrupted); } + } +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/order/handler/TradeBrokeragePaymentBoundaryTest.java b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/order/handler/TradeBrokeragePaymentBoundaryTest.java new file mode 100644 index 0000000..3f259b7 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/order/handler/TradeBrokeragePaymentBoundaryTest.java @@ -0,0 +1,94 @@ +package cn.iocoder.yudao.module.trade.service.order.handler; + +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.product.api.sku.ProductSkuApi; +import cn.iocoder.yudao.module.product.api.sku.dto.ProductSkuRespDTO; +import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi; +import cn.iocoder.yudao.module.product.api.spu.dto.ProductSpuRespDTO; +import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderDO; +import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderItemDO; +import cn.iocoder.yudao.module.trade.dal.mysql.order.TradeOrderMapper; +import cn.iocoder.yudao.module.trade.service.brokerage.BrokerageRecordService; +import cn.iocoder.yudao.module.trade.service.order.TradeOrderUpdateServiceImpl; +import cn.iocoder.yudao.module.pay.api.order.PayOrderApi; +import cn.iocoder.yudao.module.pay.api.order.dto.PayOrderRespDTO; +import org.springframework.test.util.ReflectionTestUtils; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class TradeBrokeragePaymentBoundaryTest extends BaseMockitoUnitTest { + @InjectMocks private TradeBrokerageOrderHandler handler; + @Mock private BrokerageRecordService brokerageRecordService; + @Mock private MemberUserApi memberUserApi; + @Mock private ProductSpuApi productSpuApi; + @Mock private ProductSkuApi productSkuApi; + @Mock private TradeOrderMapper tradeOrderMapper; + + @Test + void orderWithNoInviterAtCreationStillCommissionsAtPaymentAndPersistsActualInviter() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), TradeOrderDO.class); + TradeOrderDO order = new TradeOrderDO().setId(100L).setUserId(300L); + TradeOrderItemDO item = new TradeOrderItemDO().setId(101L).setUserId(300L).setSpuId(1L).setSkuId(2L) + .setPayPrice(10000).setSpuName("商品"); + when(memberUserApi.getUser(300L)).thenReturn(new MemberUserRespDTO().setId(300L).setNickname("会员")); + when(productSpuApi.getSpu(1L)).thenReturn(new ProductSpuRespDTO().setSubCommissionType(false)); + when(productSkuApi.getSku(2L)).thenReturn(new ProductSkuRespDTO()); + when(brokerageRecordService.addOrderBrokerage(eq(300L), anyList())).thenReturn(30L); + handler.afterPayOrder(order, Collections.singletonList(item)); + assertEquals(30L, order.getBrokerageUserId()); + verify(brokerageRecordService).addOrderBrokerage(eq(300L), argThat(items -> items.size() == 1 + && "101".equals(items.get(0).getBizId()) && Long.valueOf(300).equals(items.get(0).getSourceUserId()))); + verify(tradeOrderMapper).update(isNull(), any()); + } + + @Test + void refundLooksUpActualOrderItemRecordsEvenWhenOrderHasNoInviter() { + TradeOrderDO order = new TradeOrderDO().setId(100L).setUserId(300L); + handler.afterCancelOrderItem(order, new TradeOrderItemDO().setId(101L)); + verify(brokerageRecordService).cancelOrderBrokerage("101", 300L); + verifyNoMoreInteractions(brokerageRecordService); + } + @Test + void repeatedSuccessfulPaymentCallbackNeverReentersCommissionHandler() { + TradeOrderUpdateServiceImpl service = new TradeOrderUpdateServiceImpl(); + TradeOrderHandler callback = mock(TradeOrderHandler.class); + ReflectionTestUtils.setField(service, "tradeOrderMapper", tradeOrderMapper); + ReflectionTestUtils.setField(service, "tradeOrderHandlers", Collections.singletonList(callback)); + when(tradeOrderMapper.selectById(100L)).thenReturn(new TradeOrderDO().setId(100L).setStatus(10) + .setPayStatus(true).setPayOrderId(900L)); + service.updateOrderPaid(100L, 900L); + service.updateOrderPaid(100L, 900L); + verifyNoInteractions(callback); + } + + @Test + void concurrentPaymentCallbackThatLosesStatusUpdateCannotGenerateCommission() { + TradeOrderUpdateServiceImpl service = new TradeOrderUpdateServiceImpl(); + PayOrderApi payment = mock(PayOrderApi.class); + TradeOrderHandler callback = mock(TradeOrderHandler.class); + ReflectionTestUtils.setField(service, "tradeOrderMapper", tradeOrderMapper); + ReflectionTestUtils.setField(service, "payOrderApi", payment); + ReflectionTestUtils.setField(service, "tradeOrderHandlers", Collections.singletonList(callback)); + when(tradeOrderMapper.selectById(100L)).thenReturn(new TradeOrderDO().setId(100L).setStatus(0) + .setPayStatus(false).setPayPrice(10000)); + when(payment.getOrder(900L)).thenReturn(new PayOrderRespDTO() + .setStatus(10).setPrice(10000).setMerchantOrderId("100")); + when(tradeOrderMapper.updateByIdAndStatus(eq(100L), eq(0), any())).thenReturn(0); + assertThrows(RuntimeException.class, () -> service.updateOrderPaid(100L, 900L)); + verifyNoInteractions(callback); + } + +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/brokerage_income_schema.sql b/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/brokerage_income_schema.sql index b8b99f0..044fa22 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/brokerage_income_schema.sql +++ b/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/brokerage_income_schema.sql @@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS "trade_brokerage_record" ( "id" int NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "user_id" bigint NOT NULL, "biz_id" varchar DEFAULT '', + "biz_scene" varchar(32), "biz_type" int NOT NULL, "title" varchar DEFAULT '', "description" varchar DEFAULT '', diff --git a/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/brokerage_relationship_schema.sql b/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/brokerage_relationship_schema.sql index 764cded..c9a15ba 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/brokerage_relationship_schema.sql +++ b/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/brokerage_relationship_schema.sql @@ -1,6 +1,7 @@ CREATE TABLE IF NOT EXISTS "trade_brokerage_user" ( "id" bigint NOT NULL PRIMARY KEY, "bind_user_id" bigint, + "bind_version" bigint NOT NULL DEFAULT 0, "bind_user_time" datetime, "brokerage_enabled" bit DEFAULT TRUE, "brokerage_time" datetime, diff --git a/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/create_tables.sql b/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/create_tables.sql index 1d7ed24..22e063c 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/create_tables.sql +++ b/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/create_tables.sql @@ -158,6 +158,7 @@ CREATE TABLE IF NOT EXISTS "trade_brokerage_user" ( "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, "bind_user_id" bigint NOT NULL, + "bind_version" bigint NOT NULL DEFAULT 0, "bind_user_time" varchar, "brokerage_enabled" bit NOT NULL, "brokerage_time" varchar, @@ -176,6 +177,7 @@ CREATE TABLE IF NOT EXISTS "trade_brokerage_record" "id" int NOT NULL GENERATED BY DEFAULT AS IDENTITY, "user_id" bigint NOT NULL, "biz_id" varchar NOT NULL, + "biz_scene" varchar(32), "biz_type" varchar NOT NULL, "title" varchar NOT NULL, "price" int NOT NULL, diff --git a/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/api/user/MemberUserApi.java b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/api/user/MemberUserApi.java index e9ab2fc..0330a20 100644 --- a/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/api/user/MemberUserApi.java +++ b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/api/user/MemberUserApi.java @@ -1,6 +1,8 @@ package cn.iocoder.yudao.module.member.api.user; import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; import java.util.Collection; import java.util.List; @@ -31,6 +33,9 @@ public interface MemberUserApi { */ List getUserList(Collection ids); + /** 按 App ID、昵称或手机号分页搜索会员,供分销上级选择复用。 */ + PageResult searchUserPage(String searchField, String keyword, PageParam page); + /** * 获得会员用户 Map * diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/api/user/MemberUserApiImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/api/user/MemberUserApiImpl.java index 0f3a1fb..b74a288 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/api/user/MemberUserApiImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/api/user/MemberUserApiImpl.java @@ -2,6 +2,10 @@ package cn.iocoder.yudao.module.member.api.user; import cn.hutool.core.util.ObjectUtil; import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserPageReqVO; import cn.iocoder.yudao.module.member.client.service.ChatApi; import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatPrivateMessageSendResVO; import cn.iocoder.yudao.module.member.convert.user.MemberUserConvert; @@ -43,6 +47,20 @@ public class MemberUserApiImpl implements MemberUserApi { return MemberUserConvert.INSTANCE.convertList2(userService.getUserList(ids)); } + @Override + public PageResult searchUserPage(String searchField, String keyword, PageParam page) { + MemberUserPageReqVO query = new MemberUserPageReqVO(); + query.setPageNo(page.getPageNo()); + query.setPageSize(page.getPageSize()); + switch (searchField) { + case "userCode": query.setUserCode(keyword.trim()); break; + case "nickname": query.setNickname(keyword.trim()); break; + case "mobile": query.setMobile(keyword.trim()); break; + default: throw new IllegalArgumentException("不支持的会员搜索条件"); + } + return BeanUtils.toBean(userService.getUserPage(query), MemberUserRespDTO.class); + } + @Override public List getUserListByNickname(String nickname) { return MemberUserConvert.INSTANCE.convertList2(userService.getUserListByNickname(nickname)); diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/txmap/geocoder/TxMapGeocoderReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/txmap/geocoder/TxMapGeocoderReqVO.java index 284c924..5a99d98 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/txmap/geocoder/TxMapGeocoderReqVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/txmap/geocoder/TxMapGeocoderReqVO.java @@ -17,10 +17,13 @@ public class TxMapGeocoderReqVO { private String key; /** - * 经纬度(GCJ02坐标系),格式:location=lat<纬度>,lng<经度>location=39.984154,116.307490 + * 经纬度,按纬度、经度的顺序传入 */ private String location; + /** 输入坐标系,1 为 GPS,未指定时沿用腾讯地图默认的 GCJ02。 */ + private Integer coord_type; + private String radius; } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMsgController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMsgController.java index a3bfd6c..dcf675b 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMsgController.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMsgController.java @@ -1,7 +1,10 @@ package cn.iocoder.yudao.module.member.controller.admin.chat; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.member.client.constant.ChatConstant; import cn.iocoder.yudao.module.member.client.service.ChatApi; import cn.iocoder.yudao.module.member.client.vo.chat.ChatTableRespVO; import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivateMessageCountReqVO; @@ -12,6 +15,8 @@ import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivateConve import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivateConversationRespVO; import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivateMessageModerationReqVO; import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivateMessageModerationRespVO; +import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO; +import cn.iocoder.yudao.module.member.service.user.MemberUserService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.validation.annotation.Validated; @@ -21,10 +26,14 @@ import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import javax.validation.Valid; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; @Tag(name = "管理后台 - 私聊") @@ -36,10 +45,14 @@ public class ChatPrivateMsgController { @Resource private ChatApi chatApi; + @Resource + private MemberUserService memberUserService; + @PostMapping("/page") @Operation(summary = "查询私聊消息列表", description = "查询私聊消息列表") public CommonResult> page(@Valid @RequestBody ChatPrivateMessageReqVO dto) { ChatTableRespVO vo = chatApi.privateMsgPage(dto); + fillMemberNicknames(vo.getRows()); return success(new PageResult<>(vo.getRows(), vo.getTotal())); } @@ -65,9 +78,40 @@ public class ChatPrivateMsgController { ChatPrivateMessageInfoReqVO dto = new ChatPrivateMessageInfoReqVO(); dto.setMsgId(id); ChatPrivateMessageRespVO vo = chatApi.privateMsgGetInfo(dto); + if (vo != null) { + fillMemberNicknames(Collections.singletonList(vo)); + } return success(vo); } + private void fillMemberNicknames(List messages) { + if (CollUtil.isEmpty(messages)) { + return; + } + Set userIds = convertSet(messages, ChatPrivateMessageRespVO::getSendId); + userIds.addAll(convertSet(messages, ChatPrivateMessageRespVO::getRecvId)); + userIds.remove(null); + Map users = convertMap(memberUserService.getUserList(userIds), MemberUserDO::getId); + for (ChatPrivateMessageRespVO message : messages) { + message.setSendUserName(memberNickname(message.getSendId(), users)); + message.setRecvUserName(memberNickname(message.getRecvId(), users)); + } + } + + private String memberNickname(Long userId, Map users) { + MemberUserDO user = users.get(userId); + if (user != null && StrUtil.isNotBlank(user.getNickname())) { + return user.getNickname(); + } + if (ChatConstant.COM_MESSAGE_USER_ID.equals(userId)) { + return ChatConstant.COM_MESSAGE_USER_NAME; + } + if (ChatConstant.COM_SERVICE_USER_ID.equals(userId)) { + return ChatConstant.COM_SERVICE_USER_NAME; + } + return userId == null ? "未知用户" : "用户 " + userId; + } + @GetMapping("/cout") @Operation(summary = "私聊消息统计", description = "私聊消息统计") public CommonResult> dailyCount(@RequestParam("days") Integer days) { diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/location/vo/LocationSaveReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/location/vo/LocationSaveReqVO.java index 146a2bb..50f5df1 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/location/vo/LocationSaveReqVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/location/vo/LocationSaveReqVO.java @@ -24,6 +24,10 @@ public class LocationSaveReqVO { @NotNull(message = "纬度不能为空") private Double lat; + @Schema(description = "本次定位的坐标系", allowableValues = {"wgs84", "gcj02"}) + @Pattern(regexp = "wgs84|gcj02", message = "定位坐标系不正确") + private String coordinateType; + @Schema(description = "省") private String province; @@ -36,4 +40,4 @@ public class LocationSaveReqVO { @Schema(description = "详细地址") private String address; -} \ No newline at end of file +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/tea/MemberAccountClosureController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/tea/MemberAccountClosureController.java new file mode 100644 index 0000000..7076276 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/tea/MemberAccountClosureController.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.member.controller.admin.tea; +import cn.iocoder.yudao.framework.common.pojo.*; +import cn.iocoder.yudao.module.member.controller.tea.vo.AccountClosureModels.*; +import cn.iocoder.yudao.module.member.service.tea.AccountClosureService; +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 static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +@RestController @Validated @RequestMapping("/member/account-closure") +public class MemberAccountClosureController { + @Resource private AccountClosureService service; + @GetMapping("/page") @PreAuthorize("@ss.hasPermission('app:setting:query')") + public CommonResult> page(@Valid Page request) { return success(service.page(request)); } + @PutMapping("/update-status") @PreAuthorize("@ss.hasPermission('app:setting:booking')") + public CommonResult update(@RequestBody @Valid Update request) { service.update(request); return success(true); } +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/tea/MemberAppSettingController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/tea/MemberAppSettingController.java new file mode 100644 index 0000000..922751f --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/tea/MemberAppSettingController.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.member.controller.admin.tea; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.member.controller.tea.vo.TeaModels.Setting; +import cn.iocoder.yudao.module.member.service.tea.TeaRoomService; +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 static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +@RestController @Validated @RequestMapping("/member/app-setting") +public class MemberAppSettingController { + @Resource private TeaRoomService service; + @GetMapping("/get") @PreAuthorize("@ss.hasPermission('app:setting:query')") + public CommonResult get() { return success(service.getSetting(null)); } + @PutMapping("/save") @PreAuthorize("@ss.hasPermission('app:setting:update')") + public CommonResult save(@Valid @RequestBody Setting request) { service.saveSetting(request); return success(true); } +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/tea/MemberTeaBookingController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/tea/MemberTeaBookingController.java new file mode 100644 index 0000000..3abdb4f --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/tea/MemberTeaBookingController.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.member.controller.admin.tea; +import cn.iocoder.yudao.framework.common.pojo.*; +import cn.iocoder.yudao.module.member.controller.tea.vo.TeaModels.*; +import cn.iocoder.yudao.module.member.service.tea.TeaRoomService; +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 javax.validation.constraints.Positive; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +@RestController @Validated @RequestMapping("/member/tea-booking") +public class MemberTeaBookingController { + @Resource private TeaRoomService service; + @GetMapping("/page") @PreAuthorize("@ss.hasPermission('app:setting:query')") + public CommonResult> page(@Valid BookingPage request) { return success(service.page(request, null)); } + @GetMapping("/get") @PreAuthorize("@ss.hasPermission('app:setting:query')") + public CommonResult get(@RequestParam @Positive Long id) { return success(service.booking(id, null)); } + @PutMapping("/update-status") @PreAuthorize("@ss.hasPermission('app:setting:booking')") + public CommonResult update(@RequestBody @Valid StatusUpdate request) { service.updateStatus(request); return success(true); } +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/tea/MemberTeaRoomController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/tea/MemberTeaRoomController.java new file mode 100644 index 0000000..d3cd8a8 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/tea/MemberTeaRoomController.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.member.controller.admin.tea; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.member.controller.tea.vo.TeaModels.Room; +import cn.iocoder.yudao.module.member.service.tea.TeaRoomService; +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 javax.validation.constraints.Positive; +import java.util.List; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +@RestController @Validated @RequestMapping("/member/tea-room") +public class MemberTeaRoomController { + @Resource private TeaRoomService service; + @GetMapping("/list") @PreAuthorize("@ss.hasPermission('app:setting:query')") + public CommonResult> list() { return success(service.rooms(false)); } + @GetMapping("/get") @PreAuthorize("@ss.hasPermission('app:setting:query')") + public CommonResult get(@RequestParam @Positive Long id) { return success(service.room(id, false)); } + @PostMapping("/create") @PreAuthorize("@ss.hasPermission('app:setting:update')") + public CommonResult create(@RequestBody @Valid Room request) { return success(service.saveRoom(request, true)); } + @PutMapping("/update") @PreAuthorize("@ss.hasPermission('app:setting:update')") + public CommonResult update(@RequestBody @Valid Room request) { service.saveRoom(request, false); return success(true); } + @DeleteMapping("/delete") @PreAuthorize("@ss.hasPermission('app:setting:update')") + public CommonResult delete(@RequestParam @Positive Long id) { service.disableRoom(id); return success(true); } +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/tea/AppAccountClosureController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/tea/AppAccountClosureController.java new file mode 100644 index 0000000..bd734bc --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/tea/AppAccountClosureController.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.member.controller.app.tea; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.member.controller.tea.vo.AccountClosureModels.*; +import cn.iocoder.yudao.module.member.service.tea.AccountClosureService; +import org.springframework.validation.annotation.Validated; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import javax.annotation.Resource; +import javax.validation.Valid; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; +@RestController @Validated @PreAuthorize("isAuthenticated()") @RequestMapping("/member/account-closure") +public class AppAccountClosureController { + @Resource private AccountClosureService service; + @PostMapping("/create") public CommonResult create(@RequestBody @Valid Create request) { + return success(service.create(getLoginUserId(), request)); + } + @GetMapping("/latest") public CommonResult latest() { return success(service.latest(getLoginUserId())); } +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/tea/AppTeaBookingController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/tea/AppTeaBookingController.java new file mode 100644 index 0000000..27f8ec4 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/tea/AppTeaBookingController.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.member.controller.app.tea; +import cn.iocoder.yudao.framework.common.pojo.*; +import cn.iocoder.yudao.module.member.controller.tea.vo.TeaModels.*; +import cn.iocoder.yudao.module.member.service.tea.TeaRoomService; +import org.springframework.validation.annotation.Validated; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import javax.annotation.Resource; +import javax.validation.Valid; +import javax.validation.constraints.Positive; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; +@RestController @Validated @PreAuthorize("isAuthenticated()") @RequestMapping("/member/tea-booking") +public class AppTeaBookingController { + @Resource private TeaRoomService service; + @PostMapping("/create") + public CommonResult create(@Valid @RequestBody BookingCreate request) { return success(service.createBooking(getLoginUserId(), request)); } + @GetMapping("/page") + public CommonResult> page(@Valid BookingPage request) { return success(service.page(request, getLoginUserId())); } + @GetMapping("/get") + public CommonResult get(@RequestParam @Positive Long id) { return success(service.booking(id, getLoginUserId())); } + @PutMapping("/cancel") + public CommonResult cancel(@Valid @RequestBody IdRequest request) { service.cancel(request.getId(), getLoginUserId()); return success(true); } +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/tea/AppTeaPublicController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/tea/AppTeaPublicController.java new file mode 100644 index 0000000..a466166 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/tea/AppTeaPublicController.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.member.controller.app.tea; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.member.controller.tea.vo.TeaModels.*; +import cn.iocoder.yudao.module.member.service.tea.TeaRoomService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import javax.annotation.Resource; +import javax.annotation.security.PermitAll; +import javax.servlet.http.HttpServletResponse; +import javax.validation.constraints.*; +import java.util.List; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +@RestController @Validated @RequestMapping("/member") +public class AppTeaPublicController { + @Resource private TeaRoomService service; + @PermitAll @GetMapping("/app-setting/get") + public CommonResult setting(@RequestParam @Pattern(regexp = "ios|android|h5") String platform, + HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store"); + return success(service.getSetting(platform)); + } + @PermitAll @GetMapping("/tea-room/list") + public CommonResult> list() { return success(service.rooms(true)); } + @PermitAll @GetMapping("/tea-room/get") + public CommonResult room(@RequestParam @Positive Long id) { return success(service.room(id, true)); } + @PermitAll @GetMapping("/tea-room/slots") + public CommonResult> slots(@RequestParam @Positive Long roomId, + @RequestParam @Pattern(regexp = "[0-9]{4}-[0-9]{2}-[0-9]{2}") String date, + HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store"); + return success(service.slots(roomId, date)); + } +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/tea/vo/AccountClosureModels.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/tea/vo/AccountClosureModels.java new file mode 100644 index 0000000..4f13212 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/tea/vo/AccountClosureModels.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.member.controller.tea.vo; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; +import javax.validation.constraints.*; +import java.time.LocalDateTime; + +public final class AccountClosureModels { + private AccountClosureModels() {} + @Data public static class Create { + @Size(max = 500) private String reason; + @Pattern(regexp = "1[3-9][0-9]{9}") private String contactPhone; + } + @Data @EqualsAndHashCode(callSuper = true) public static class Page extends PageParam { + @Pattern(regexp = "pending|processing|resolved|rejected") private String status; + @Size(max = 80) private String keyword; + } + @Data public static class Update { + @NotNull @Positive private Long id; + @NotBlank @Pattern(regexp = "processing|resolved|rejected") private String status; + @NotBlank @Size(max = 1000) private String processRemark; + } + @Data public static class Response { + private Long id; + private Long memberId; + private String nickname; + private String contactPhone; + private String reason; + private String status; + private String processRemark; + private LocalDateTime createTime; + private LocalDateTime updateTime; + } +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/tea/vo/TeaModels.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/tea/vo/TeaModels.java new file mode 100644 index 0000000..7a2190c --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/tea/vo/TeaModels.java @@ -0,0 +1,129 @@ +package cn.iocoder.yudao.module.member.controller.tea.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; +import javax.validation.Valid; +import javax.validation.constraints.*; +import java.util.ArrayList; +import java.util.List; +import java.time.LocalDateTime; + +/** 单店预约的公共输入和输出;金额统一为分,时间统一为中国标准时间。 */ +public final class TeaModels { + private TeaModels() {} + + @Data + public static class Slot { + @NotBlank @Pattern(regexp = "([01][0-9]|2[0-3]):[0-5][0-9]") + private String startTime; + @NotBlank @Pattern(regexp = "([01][0-9]|2[0-3]):[0-5][0-9]") + private String endTime; + } + + @Data @EqualsAndHashCode(callSuper = true) + public static class AvailableSlot extends Slot { + private Boolean available; + } + + @Data + public static class Setting { + @NotBlank @Pattern(regexp = "chat|tea") + private String iosMode = "chat"; + @NotBlank @Pattern(regexp = "chat|tea") + private String androidMode = "chat"; + @NotBlank @Pattern(regexp = "chat|tea") + private String h5Mode = "chat"; + @Size(max = 80) private String storeName = ""; + @Size(max = 2048) private String storeCover = ""; + @Size(max = 2000) private String storeDescription = ""; + @Size(max = 300) private String storeAddress = ""; + @Size(max = 30) private String storePhone = ""; + @Pattern(regexp = "([01][0-9]|2[0-3]):[0-5][0-9]") + private String openTime = "10:00"; + @Pattern(regexp = "([01][0-9]|2[0-3]):[0-5][0-9]") + private String closeTime = "22:00"; + @Size(max = 2000) private String bookingNotice = ""; + @NotNull @Size(max = 12) @Valid + private List<@NotNull @Valid Slot> slots = new ArrayList<>(); + private String currentMode; + } + + @Data + public static class Room { + @Positive private Long id; + @NotBlank @Size(max = 80) private String name; + @Size(max = 2048) private String cover = ""; + @NotNull @Min(1) @Max(50) private Integer capacity; + @NotNull @Size(max = 12) + private List<@NotBlank @Size(max = 30) String> facilities = new ArrayList<>(); + @Size(max = 2000) private String description = ""; + @NotNull @Min(0) private Integer price; + @NotNull @Min(0) @Max(9999) private Integer sort = 0; + @NotNull private Boolean enabled = true; + } + + @Data + public static class BookingCreate { + @NotNull @Positive private Long roomId; + @NotBlank @Pattern(regexp = "[0-9]{4}-[0-9]{2}-[0-9]{2}") private String date; + @NotBlank @Pattern(regexp = "([01][0-9]|2[0-3]):[0-5][0-9]") private String startTime; + @NotBlank @Pattern(regexp = "([01][0-9]|2[0-3]):[0-5][0-9]") private String endTime; + @NotNull @Min(1) @Max(50) private Integer persons; + @NotBlank @Size(max = 40) private String contactName; + @NotBlank @Pattern(regexp = "1[3-9][0-9]{9}") private String contactPhone; + @Size(max = 500) private String remark = ""; + @Size(max = 64) private String requestId; + } + + @Data @EqualsAndHashCode(callSuper = true) + public static class BookingPage extends PageParam { + @Pattern(regexp = "pending|confirmed|completed|cancelled|rejected|expired|ended") + private String status; + @Positive private Long roomId; + @Pattern(regexp = "[0-9]{4}-[0-9]{2}-[0-9]{2}") private String date; + @Size(max = 80) private String keyword; + } + + @Data + public static class StatusUpdate { + @NotNull @Positive private Long id; + @NotBlank @Pattern(regexp = "confirmed|completed|cancelled|rejected") + private String status; + @Size(max = 500) private String reason; + } + + @Data + public static class IdRequest { + @NotNull @Positive private Long id; + } + + @Data + public static class Booking { + private Long id; + private String bookingNo; + private Long roomId; + private String roomName; + private String roomCover; + private String storeName; + private String storeAddress; + private String storePhone; + private String date; + private String startTime; + private String endTime; + private Integer persons; + private String contactName; + private String contactPhone; + private String remark; + private Integer price; + private String status; + private String reason; + private LocalDateTime createTime; + private Boolean canCancel; + private Boolean canConfirm; + private Boolean canReject; + private Boolean canComplete; + private Boolean canAdminCancel; + } +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/tea/MemberAccountClosureDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/tea/MemberAccountClosureDO.java new file mode 100644 index 0000000..a384ae2 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/tea/MemberAccountClosureDO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.tea; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +/** 人工注销申请,状态变更本身不删除、停用或修改会员账号。 */ +@Data @EqualsAndHashCode(callSuper = true) +@TableName("member_account_closure_request") +public class MemberAccountClosureDO extends TenantBaseDO { + @TableId private Long id; + private Long memberId; + private String nickname; + private String contactPhone; + private String reason; + private String status; + private String processRemark; +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/tea/MemberAppSettingDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/tea/MemberAppSettingDO.java new file mode 100644 index 0000000..88446c1 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/tea/MemberAppSettingDO.java @@ -0,0 +1,17 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.tea; + +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 设置,JSON 仅储存经过服务端校验的公开店铺资料。 */ +@Data @EqualsAndHashCode(callSuper = true) +@TableName("member_app_setting") +public class MemberAppSettingDO extends TenantBaseDO { + @TableId private Long id; + private String configKey; + private String configJson; +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/tea/MemberTeaBookingDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/tea/MemberTeaBookingDO.java new file mode 100644 index 0000000..ba3075b --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/tea/MemberTeaBookingDO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.tea; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data @EqualsAndHashCode(callSuper = true) +@TableName("member_tea_booking") +public class MemberTeaBookingDO extends TenantBaseDO { + @TableId private Long id; + private String bookingNo; + private Long memberId; + private Long roomId; + private String roomName; + private String roomCover; + private String storeName; + private String storeAddress; + private String storePhone; + private LocalDate date; + private String startTime; + private String endTime; + private LocalDateTime startAt; + private LocalDateTime endAt; + private Integer persons; + private String contactName; + private String contactPhone; + private String remark; + private Integer price; + private String status; + private String reason; + private String requestId; + private String requestHash; +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/tea/MemberTeaRoomDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/tea/MemberTeaRoomDO.java new file mode 100644 index 0000000..57280ec --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/tea/MemberTeaRoomDO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.tea; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.*; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import java.util.List; + +@Data @EqualsAndHashCode(callSuper = true) +@TableName(value = "member_tea_room", autoResultMap = true) +public class MemberTeaRoomDO extends TenantBaseDO { + @TableId private Long id; + private String name; + private String cover; + private Integer capacity; + @TableField(typeHandler = JacksonTypeHandler.class) + private List facilities; + private String description; + private Integer price; + private Integer sort; + private Boolean enabled; +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/tea/MemberAccountClosureMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/tea/MemberAccountClosureMapper.java new file mode 100644 index 0000000..69e8c62 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/tea/MemberAccountClosureMapper.java @@ -0,0 +1,9 @@ +package cn.iocoder.yudao.module.member.dal.mysql.tea; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.member.dal.dataobject.tea.MemberAccountClosureDO; +import org.apache.ibatis.annotations.*; +@Mapper public interface MemberAccountClosureMapper extends BaseMapperX { + @Select("SELECT * FROM member_account_closure_request WHERE id = #{id} AND deleted = 0 FOR UPDATE") + MemberAccountClosureDO selectForUpdate(@Param("id") Long id); +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/tea/MemberAppSettingMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/tea/MemberAppSettingMapper.java new file mode 100644 index 0000000..d99f69f --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/tea/MemberAppSettingMapper.java @@ -0,0 +1,10 @@ +package cn.iocoder.yudao.module.member.dal.mysql.tea; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.member.dal.dataobject.tea.MemberAppSettingDO; +import org.apache.ibatis.annotations.*; +@Mapper +public interface MemberAppSettingMapper extends BaseMapperX { + @Select("SELECT * FROM member_app_setting WHERE config_key = 'main' AND deleted = 0 FOR UPDATE") + MemberAppSettingDO selectCurrentForUpdate(); +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/tea/MemberTeaBookingMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/tea/MemberTeaBookingMapper.java new file mode 100644 index 0000000..5f7ebab --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/tea/MemberTeaBookingMapper.java @@ -0,0 +1,11 @@ +package cn.iocoder.yudao.module.member.dal.mysql.tea; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.member.dal.dataobject.tea.MemberTeaBookingDO; +import org.apache.ibatis.annotations.*; +@Mapper +public interface MemberTeaBookingMapper extends BaseMapperX { + /** 加锁读取避免事务快照漏掉刚刚确认的预约。 */ + @Select("SELECT * FROM member_tea_booking WHERE id = #{id} AND deleted = 0 FOR UPDATE") + MemberTeaBookingDO selectForUpdate(@Param("id") Long id); +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/tea/MemberTeaRoomMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/tea/MemberTeaRoomMapper.java new file mode 100644 index 0000000..3bab01c --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/tea/MemberTeaRoomMapper.java @@ -0,0 +1,11 @@ +package cn.iocoder.yudao.module.member.dal.mysql.tea; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.member.dal.dataobject.tea.MemberTeaRoomDO; +import org.apache.ibatis.annotations.*; +@Mapper +public interface MemberTeaRoomMapper extends BaseMapperX { + /** 包间作为所有预约写操作的共同锁,查询仍经过租户组件。 */ + @Select("SELECT * FROM member_tea_room WHERE id = #{id} AND deleted = 0 FOR UPDATE") + MemberTeaRoomDO selectForUpdate(@Param("id") Long id); +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/location/LocationServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/location/LocationServiceImpl.java index 72b8d7a..f98e99f 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/location/LocationServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/location/LocationServiceImpl.java @@ -47,7 +47,10 @@ public class LocationServiceImpl implements LocationService { @Transactional(rollbackFor = Exception.class) public Long createLocation(LocationSaveReqVO dto) { if (!ObjectUtil.isAllNotEmpty(dto.getProvince(), dto.getCity(), dto.getDistrict())) { - TxMapGeocoderRespVO geocoder = txMapApi.geocoder(TxMapGeocoderReqVO.builder().location(dto.getLat() + "," + dto.getLon()).build()); + TxMapGeocoderRespVO geocoder = txMapApi.geocoder(TxMapGeocoderReqVO.builder() + .location(dto.getLat() + "," + dto.getLon()) + .coord_type("wgs84".equals(dto.getCoordinateType()) ? 1 : null) + .build()); if (ObjectUtil.isNotEmpty(geocoder) && ObjectUtil.isNotEmpty(geocoder.getAddress_component())) { TxMapGeocoderRespVO.AddressComponent addressComponent = geocoder.getAddress_component(); dto.setProvince(addressComponent.getProvince()); @@ -147,4 +150,4 @@ public class LocationServiceImpl implements LocationService { } } -} \ No newline at end of file +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java index 6bd0d76..d39edc6 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java @@ -352,6 +352,10 @@ public class OpenTypeServiceImpl implements OpenTypeService { */ @Override public boolean isUserCanSendMessage(Long sendId, Long receiveId) { + // 联系客服不占用会员的交友聊天额度。 + if (ChatConstant.COM_SERVICE_USER_ID.equals(receiveId)) { + return true; + } // 获取配置 MemberConfigDO config = memberConfigService.getConfig(); // 判断是否是会员 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 index 6a4ca83..5a08ddb 100644 --- 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 @@ -7,6 +7,8 @@ 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.service.tea.TeaRoomService; +import cn.iocoder.yudao.module.member.controller.tea.vo.TeaModels.Setting; 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; @@ -43,6 +45,7 @@ public class MemberPushTaskService { @Resource private JPushGateway gateway; @Resource private RedissonClient redisson; @Resource private TransactionTemplate transactions; + @Resource private TeaRoomService teaRoomService; private long lastScanErrorAt; public Long createManual(MemberPushSendReqVO req, Long operatorId) { @@ -241,7 +244,11 @@ public class MemberPushTaskService { .filter(record -> "WAITING".equals(record.getStatus())).collect(Collectors.toList()); List eligible = filterEligible(task, fresh); if (!eligible.isEmpty()) { - sendNewBatch(task, eligible); + // 每个平台有独立 CID,单端切换业务不影响另一端重试。 + for (List platformBatch : eligible.stream() + .collect(Collectors.groupingBy(MemberPushRecordDO::getPlatform)).values()) { + sendNewBatch(task, platformBatch); + } } } refreshSummary(task); @@ -323,6 +330,13 @@ public class MemberPushTaskService { } private List filterEligible(MemberPushTaskDO task, List records) { + Setting setting = teaRoomService.getSetting(null); + records = records.stream().filter(record -> { + if (notificationAllowed(setting, record.getPlatform())) return true; + skip(record, "APP_MODE_TEA", "该平台当前为茶室预约,暂停原聊天通知"); + return false; + }).collect(Collectors.toList()); + if (records.isEmpty()) return Collections.emptyList(); ChatPrivatePushContextRespVO context = null; if ("AUTO".equals(task.getSource())) { context = chatApi.privatePushContext(task.getMessageId()); @@ -429,6 +443,11 @@ public class MemberPushTaskService { if (records.isEmpty()) { return; } + if (!batchModeAllowed(records)) { + cancelUnconfirmedBatch(task, cid, "APP_MODE_CHANGED_UNCONFIRMED", + "平台已切换为茶室预约,原批次提交结果未确认,已结束重试"); + return; + } List deviceIds = records.stream().map(MemberPushRecordDO::getDeviceId).collect(Collectors.toList()); Map devices = deviceMapper.selectList(new LambdaQueryWrapper() .in(MemberPushDeviceDO::getId, deviceIds) @@ -488,6 +507,12 @@ public class MemberPushTaskService { } private void submitBatch(MemberPushTaskDO task, List records, String cid, String payload) { + // 获取 CID 或排队期间也可能切换模式,提交前再读一次当前配置。 + if (!batchModeAllowed(records)) { + cancelUnconfirmedBatch(task, cid, "APP_MODE_CHANGED_UNCONFIRMED", + "平台已切换为茶室预约,停止提交当前通知批次"); + return; + } try { String msgId = gateway.send(payload); recordMapper.update(null, batch(task.getId(), cid) @@ -514,6 +539,16 @@ public class MemberPushTaskService { } } + private boolean batchModeAllowed(List records) { + Setting setting = teaRoomService.getSetting(null); + return records.stream().allMatch(record -> notificationAllowed(setting, record.getPlatform())); + } + + private boolean notificationAllowed(Setting setting, String platform) { + if (!"ios".equals(platform) && !"android".equals(platform)) return false; + return "chat".equals(teaRoomService.mode(setting, platform)); + } + private LambdaUpdateWrapper batch(Long taskId, String cid) { return new LambdaUpdateWrapper() .eq(MemberPushRecordDO::getTaskId, taskId).eq(MemberPushRecordDO::getCid, cid) diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/tea/AccountClosureService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/tea/AccountClosureService.java new file mode 100644 index 0000000..d1d31f3 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/tea/AccountClosureService.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.member.service.tea; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.member.controller.tea.vo.AccountClosureModels.*; +import cn.iocoder.yudao.module.member.dal.dataobject.tea.MemberAccountClosureDO; +import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO; +import cn.iocoder.yudao.module.member.dal.mysql.tea.MemberAccountClosureMapper; +import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import javax.annotation.Resource; +import java.util.List; +import java.util.stream.Collectors; +import static cn.iocoder.yudao.module.member.service.tea.TeaRoomService.bad; + +/** 只受理真实人工申请,不在提交或更新申请状态时自动注销账号。 */ +@Service public class AccountClosureService { + @Resource private MemberAccountClosureMapper mapper; + @Resource private MemberUserMapper userMapper; + + @Transactional(rollbackFor = Exception.class) + public Long create(Long memberId, Create request) { + MemberUserDO member = userMapper.selectForChatSync(memberId); + if (member == null || Boolean.TRUE.equals(member.getDeleted()) || !Integer.valueOf(0).equals(member.getStatus())) + throw bad("当前会员不可用"); + List active = mapper.selectList(new LambdaQueryWrapper() + .eq(MemberAccountClosureDO::getMemberId, memberId) + .in(MemberAccountClosureDO::getStatus, "pending", "processing").last("FOR UPDATE")); + if (!active.isEmpty()) return active.get(0).getId(); + MemberAccountClosureDO row = new MemberAccountClosureDO(); + row.setMemberId(memberId); + row.setNickname(member.getNickname()); + row.setContactPhone(StrUtil.blankToDefault(request.getContactPhone(), member.getMobile())); + row.setReason(StrUtil.trimToEmpty(request.getReason())); + row.setStatus("pending"); + row.setProcessRemark(""); + mapper.insert(row); + return row.getId(); + } + + public Response latest(Long memberId) { + List rows = mapper.selectList(new LambdaQueryWrapper() + .eq(MemberAccountClosureDO::getMemberId, memberId).orderByDesc(MemberAccountClosureDO::getId).last("LIMIT 1")); + return rows.isEmpty() ? null : BeanUtils.toBean(rows.get(0), Response.class); + } + + public PageResult page(Page request) { + LambdaQueryWrapper query = new LambdaQueryWrapper() + .eq(StrUtil.isNotBlank(request.getStatus()), MemberAccountClosureDO::getStatus, request.getStatus()); + if (StrUtil.isNotBlank(request.getKeyword())) { + query.and(q -> q.like(MemberAccountClosureDO::getNickname, request.getKeyword()) + .or().like(MemberAccountClosureDO::getContactPhone, request.getKeyword())); + } + PageResult page = mapper.selectPage(request, query.orderByDesc(MemberAccountClosureDO::getId)); + return new PageResult<>(page.getList().stream().map(row -> BeanUtils.toBean(row, Response.class)) + .collect(Collectors.toList()), page.getTotal()); + } + + @Transactional(rollbackFor = Exception.class) + public void update(Update request) { + MemberAccountClosureDO row = mapper.selectForUpdate(request.getId()); + if (row == null) throw bad("注销申请不存在"); + if (!"pending".equals(row.getStatus()) && !"processing".equals(row.getStatus())) throw bad("该申请已处理,不可重复修改"); + if ("processing".equals(row.getStatus()) && "processing".equals(request.getStatus())) throw bad("该申请已经处理中"); + row.setStatus(request.getStatus()); + row.setProcessRemark(request.getProcessRemark().trim()); + mapper.updateById(row); + } +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/tea/TeaRoomService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/tea/TeaRoomService.java new file mode 100644 index 0000000..1cea0fa --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/tea/TeaRoomService.java @@ -0,0 +1,364 @@ +package cn.iocoder.yudao.module.member.service.tea; + +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.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.member.controller.tea.vo.TeaModels.*; +import cn.iocoder.yudao.module.member.dal.dataobject.tea.*; +import cn.iocoder.yudao.module.member.dal.mysql.tea.*; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.apache.commons.codec.digest.DigestUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.dao.DuplicateKeyException; +import javax.annotation.Resource; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.*; +import java.util.stream.Collectors; + +/** 单店到店付款预约。所有预约写操作锁同一个包间,不占用待确认申请的库存。 */ +@Service +public class TeaRoomService { + public static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + @Resource private MemberAppSettingMapper settingMapper; + @Resource private MemberTeaRoomMapper roomMapper; + @Resource private MemberTeaBookingMapper bookingMapper; + private Clock clock = Clock.system(ZONE); + + LocalDateTime now() { return LocalDateTime.now(clock.withZone(ZONE)); } + + public Setting getSetting(String platform) { + MemberAppSettingDO row = settingMapper.selectOne(MemberAppSettingDO::getConfigKey, "main"); + Setting setting = row == null ? new Setting() : JSONUtil.toBean(row.getConfigJson(), Setting.class); + if (setting.getSlots() == null) setting.setSlots(new ArrayList<>()); + if (platform != null) setting.setCurrentMode(mode(setting, platform)); + return setting; + } + + public String mode(Setting setting, String platform) { + if ("ios".equals(platform)) return setting.getIosMode(); + if ("android".equals(platform)) return setting.getAndroidMode(); + if ("h5".equals(platform)) return setting.getH5Mode(); + throw bad("不支持的平台"); + } + + /** 当前通知通道全部来自原聊天业务;茶室 v1 不发送通知。 */ + public boolean pushAllowed(String platform) { + if (!"ios".equals(platform) && !"android".equals(platform)) return false; + return "chat".equals(mode(getSetting(null), platform)); + } + + @Transactional(rollbackFor = Exception.class) + public void saveSetting(Setting setting) { + validateSetting(setting); + setting.setCurrentMode(null); + MemberAppSettingDO existing = settingMapper.selectOne(MemberAppSettingDO::getConfigKey, "main"); + MemberAppSettingDO row = new MemberAppSettingDO(); + row.setConfigKey("main"); + row.setConfigJson(JSONUtil.toJsonStr(setting)); + if (existing == null) { + try { + settingMapper.insert(row); + } catch (DuplicateKeyException concurrentSave) { + MemberAppSettingDO created = settingMapper.selectCurrentForUpdate(); + if (created == null) throw concurrentSave; + row.setId(created.getId()); + settingMapper.updateById(row); + } + } else { + row.setId(existing.getId()); + settingMapper.updateById(row); + } + } + + void validateSetting(Setting setting) { + if (setting.getSlots() == null || setting.getSlots().size() > 12) throw bad("最多配置12个预约时段"); + boolean teaEnabled = "tea".equals(setting.getIosMode()) || "tea".equals(setting.getAndroidMode()) + || "tea".equals(setting.getH5Mode()); + List slots = new ArrayList<>(setting.getSlots()); + LocalTime open = time(setting.getOpenTime()), close = time(setting.getCloseTime()); + if (!open.isBefore(close)) throw bad("营业开始时间需早于结束时间,暂不支持跨日营业"); + slots.sort(Comparator.comparing(Slot::getStartTime)); + LocalTime previousEnd = null; + for (Slot slot : slots) { + LocalTime start = time(slot.getStartTime()), end = time(slot.getEndTime()); + if (!start.isBefore(end) || start.isBefore(open) || end.isAfter(close)) { + throw bad("预约时段需在营业时间内,且开始时间早于结束时间"); + } + if (previousEnd != null && start.isBefore(previousEnd)) throw bad("预约时段不能重叠"); + previousEnd = end; + } + setting.setSlots(slots); + if (teaEnabled) { + requireStore(setting); + if (roomMapper.selectCount(new LambdaQueryWrapper() + .eq(MemberTeaRoomDO::getEnabled, true)) == 0) throw bad("请先添加至少一个上架包间,再启用茶室预约"); + } + } + + private void requireStore(Setting setting) { + if (StrUtil.isBlank(setting.getStoreName()) || StrUtil.isBlank(setting.getStoreCover()) + || StrUtil.isBlank(setting.getStoreAddress()) || StrUtil.isBlank(setting.getStorePhone()) + || setting.getSlots().isEmpty()) throw bad("请完善门店名称、封面、地址、电话和预约时段"); + imageUrl(setting.getStoreCover()); + } + + public List rooms(boolean publicOnly) { + return roomMapper.selectList(new LambdaQueryWrapper() + .eq(publicOnly, MemberTeaRoomDO::getEnabled, true) + .orderByAsc(MemberTeaRoomDO::getSort).orderByAsc(MemberTeaRoomDO::getId)).stream() + .map(row -> BeanUtils.toBean(row, Room.class)).collect(Collectors.toList()); + } + + public Room room(Long id, boolean publicOnly) { + MemberTeaRoomDO row = roomMapper.selectById(id); + if (row == null || (publicOnly && !Boolean.TRUE.equals(row.getEnabled()))) throw bad("包间不存在或已下架"); + return BeanUtils.toBean(row, Room.class); + } + + @Transactional(rollbackFor = Exception.class) + public Long saveRoom(Room request, boolean create) { + String cover = StrUtil.trimToEmpty(request.getCover()); + if (StrUtil.isNotBlank(cover)) imageUrl(cover); + MemberTeaRoomDO row = BeanUtils.toBean(request, MemberTeaRoomDO.class); + row.setCover(cover); + row.setName(request.getName().trim()); + row.setFacilities(request.getFacilities().stream().map(String::trim).distinct().collect(Collectors.toList())); + if (create) { + row.setId(null); + roomMapper.insert(row); + } else { + if (row.getId() == null || roomMapper.selectForUpdate(row.getId()) == null) throw bad("包间不存在"); + roomMapper.updateById(row); + } + return row.getId(); + } + + @Transactional(rollbackFor = Exception.class) + public void disableRoom(Long id) { + if (roomMapper.selectForUpdate(id) == null) throw bad("包间不存在"); + MemberTeaRoomDO row = new MemberTeaRoomDO(); + row.setId(id); + row.setEnabled(false); + roomMapper.updateById(row); + } + + public List slots(Long roomId, String date) { + room(roomId, true); + LocalDate day = bookingDate(date); + Setting setting = getSetting(null); + List confirmed = bookingMapper.selectList(new LambdaQueryWrapper() + .eq(MemberTeaBookingDO::getRoomId, roomId).eq(MemberTeaBookingDO::getDate, day) + .eq(MemberTeaBookingDO::getStatus, "confirmed")); + LocalDateTime current = now(); + return setting.getSlots().stream().map(slot -> { + AvailableSlot result = BeanUtils.toBean(slot, AvailableSlot.class); + LocalDateTime start = day.atTime(time(slot.getStartTime())), end = day.atTime(time(slot.getEndTime())); + result.setAvailable(start.isAfter(current) && confirmed.stream() + .noneMatch(row -> overlap(start, end, row.getStartAt(), row.getEndAt()))); + return result; + }).collect(Collectors.toList()); + } + + @Transactional(rollbackFor = Exception.class) + public Long createBooking(Long memberId, BookingCreate request) { + MemberTeaRoomDO room = roomMapper.selectForUpdate(request.getRoomId()); + if (room == null) throw bad("包间不存在或已下架"); + String requestId = StrUtil.blankToDefault(StrUtil.trim(request.getRequestId()), null); + String hash = DigestUtils.sha256Hex(JSONUtil.toJsonStr(Arrays.asList(request.getRoomId(), request.getDate(), + request.getStartTime(), request.getEndTime(), request.getPersons(), request.getContactName().trim(), + request.getContactPhone(), StrUtil.trimToEmpty(request.getRemark())))); + if (requestId != null) { + List repeats = bookingMapper.selectList(new LambdaQueryWrapper() + .eq(MemberTeaBookingDO::getMemberId, memberId).eq(MemberTeaBookingDO::getRequestId, requestId) + .last("FOR UPDATE")); + if (!repeats.isEmpty()) { + if (!hash.equals(repeats.get(0).getRequestHash())) throw bad("同一请求编号不能提交不同预约"); + return repeats.get(0).getId(); + } + } + if (!Boolean.TRUE.equals(room.getEnabled())) throw bad("包间不存在或已下架"); + LocalDate date = bookingDate(request.getDate()); + LocalDateTime start = date.atTime(time(request.getStartTime())), end = date.atTime(time(request.getEndTime())); + if (!start.isAfter(now())) throw bad("该预约时段已开始,请重新选择"); + if (request.getPersons() > room.getCapacity()) throw bad("预约人数超过包间容量"); + Setting setting = getSetting(null); + requireStore(setting); + boolean configured = setting.getSlots().stream().anyMatch(slot -> Objects.equals(slot.getStartTime(), request.getStartTime()) + && Objects.equals(slot.getEndTime(), request.getEndTime())); + if (!configured) throw bad("预约时段已调整,请刷新后重新选择"); + List duplicates = bookingMapper.selectList(new LambdaQueryWrapper() + .eq(MemberTeaBookingDO::getMemberId, memberId).eq(MemberTeaBookingDO::getRoomId, room.getId()) + .eq(MemberTeaBookingDO::getStartAt, start).eq(MemberTeaBookingDO::getEndAt, end) + .in(MemberTeaBookingDO::getStatus, "pending", "confirmed").last("FOR UPDATE")); + if (!duplicates.isEmpty()) return duplicates.get(0).getId(); + requireAvailable(room.getId(), start, end, null); + MemberTeaBookingDO row = BeanUtils.toBean(request, MemberTeaBookingDO.class); + row.setMemberId(memberId); + row.setDate(date); + row.setStartAt(start); + row.setEndAt(end); + row.setContactName(request.getContactName().trim()); + row.setRemark(StrUtil.trimToEmpty(request.getRemark())); + row.setRequestId(requestId); + row.setRequestHash(hash); + row.setRoomName(room.getName()); + row.setRoomCover(room.getCover()); + row.setPrice(room.getPrice()); + row.setStoreName(setting.getStoreName()); + row.setStoreAddress(setting.getStoreAddress()); + row.setStorePhone(setting.getStorePhone()); + row.setStatus("pending"); + row.setReason(""); + row.setBookingNo("T" + now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT)); + bookingMapper.insert(row); + return row.getId(); + } + + /** 必须持有包间锁;锁定读取使并发确认看到前一事务已经提交的结果。 */ + private void requireAvailable(Long roomId, LocalDateTime start, LocalDateTime end, Long excludeId) { + List conflict = bookingMapper.selectList(new LambdaQueryWrapper() + .eq(MemberTeaBookingDO::getRoomId, roomId).eq(MemberTeaBookingDO::getStatus, "confirmed") + .lt(MemberTeaBookingDO::getStartAt, end).gt(MemberTeaBookingDO::getEndAt, start) + .ne(excludeId != null, MemberTeaBookingDO::getId, excludeId).last("FOR UPDATE")); + if (!conflict.isEmpty()) throw bad("该时段已有已确认预约,请联系门店调整时间"); + } + + public Booking booking(Long id, Long memberId) { + MemberTeaBookingDO row = bookingMapper.selectById(id); + requireOwner(row, memberId); + return response(row, memberId == null); + } + + public PageResult page(BookingPage request, Long memberId) { + LambdaQueryWrapper query = new LambdaQueryWrapper() + .eq(memberId != null, MemberTeaBookingDO::getMemberId, memberId) + .eq(request.getRoomId() != null, MemberTeaBookingDO::getRoomId, request.getRoomId()); + if (StrUtil.isNotBlank(request.getDate())) query.eq(MemberTeaBookingDO::getDate, parseDate(request.getDate())); + if ("ended".equals(request.getStatus())) { + query.and(q -> q.in(MemberTeaBookingDO::getStatus, "completed", "cancelled", "rejected") + .or(expired -> expired.eq(MemberTeaBookingDO::getStatus, "pending").le(MemberTeaBookingDO::getStartAt, now()))); + } else if ("expired".equals(request.getStatus())) { + query.eq(MemberTeaBookingDO::getStatus, "pending").le(MemberTeaBookingDO::getStartAt, now()); + } else if (StrUtil.isNotBlank(request.getStatus())) { + query.eq(MemberTeaBookingDO::getStatus, request.getStatus()); + if ("pending".equals(request.getStatus())) query.gt(MemberTeaBookingDO::getStartAt, now()); + } + if (StrUtil.isNotBlank(request.getKeyword())) { + String keyword = request.getKeyword().trim(); + query.and(q -> q.like(MemberTeaBookingDO::getBookingNo, keyword) + .or().like(MemberTeaBookingDO::getContactName, keyword) + .or().like(MemberTeaBookingDO::getContactPhone, keyword)); + } + PageResult page = bookingMapper.selectPage(request, query.orderByDesc(MemberTeaBookingDO::getId)); + return new PageResult<>(page.getList().stream().map(row -> response(row, memberId == null)) + .collect(Collectors.toList()), page.getTotal()); + } + + @Transactional(rollbackFor = Exception.class) + public void cancel(Long id, Long memberId) { + MemberTeaBookingDO row = lockBooking(id, memberId); + if ("cancelled".equals(row.getStatus())) return; + if (!Boolean.TRUE.equals(response(row, false).getCanCancel())) throw bad("该预约当前不能取消,请联系门店"); + row.setStatus("cancelled"); + row.setReason("用户取消"); + bookingMapper.updateById(row); + } + + @Transactional(rollbackFor = Exception.class) + public void updateStatus(StatusUpdate request) { + MemberTeaBookingDO row = lockBooking(request.getId(), null); + if (Objects.equals(row.getStatus(), request.getStatus())) return; + Booking current = response(row, true); + switch (request.getStatus()) { + case "confirmed": + if (!current.getCanConfirm()) throw bad("只有未开始的待确认预约可以确认"); + MemberTeaRoomDO room = roomMapper.selectForUpdate(row.getRoomId()); + if (room == null || !Boolean.TRUE.equals(room.getEnabled())) throw bad("包间已下架,不能确认该预约"); + if (row.getPersons() > room.getCapacity()) throw bad("包间容量已调整,请先与顾客确认人数"); + MemberAppSettingDO latestSetting = settingMapper.selectCurrentForUpdate(); + Setting setting = latestSetting == null ? new Setting() : JSONUtil.toBean(latestSetting.getConfigJson(), Setting.class); + if (setting.getSlots().stream().noneMatch(slot -> Objects.equals(slot.getStartTime(), row.getStartTime()) + && Objects.equals(slot.getEndTime(), row.getEndTime()))) throw bad("预约时段已调整,请联系顾客重新预约"); + requireAvailable(row.getRoomId(), row.getStartAt(), row.getEndAt(), row.getId()); + break; + case "rejected": + if (!current.getCanReject()) throw bad("该预约当前不能拒绝"); + if (StrUtil.isBlank(request.getReason())) throw bad("请填写拒绝原因"); + break; + case "completed": + if (!current.getCanComplete()) throw bad("已确认预约须到结束时间后才能标记完成"); + break; + case "cancelled": + if (!current.getCanAdminCancel()) throw bad("该预约当前不能取消"); + if (StrUtil.isBlank(request.getReason())) throw bad("请填写取消原因"); + break; + default: throw bad("不支持的预约状态"); + } + row.setStatus(request.getStatus()); + row.setReason(StrUtil.trimToEmpty(request.getReason())); + bookingMapper.updateById(row); + } + + private MemberTeaBookingDO lockBooking(Long id, Long memberId) { + MemberTeaBookingDO snapshot = bookingMapper.selectById(id); + requireOwner(snapshot, memberId); + if (roomMapper.selectForUpdate(snapshot.getRoomId()) == null) throw bad("预约包间不存在"); + MemberTeaBookingDO current = bookingMapper.selectForUpdate(id); + requireOwner(current, memberId); + return current; + } + + private void requireOwner(MemberTeaBookingDO row, Long memberId) { + if (row == null || (memberId != null && !memberId.equals(row.getMemberId()))) throw bad("预约不存在"); + } + + Booking response(MemberTeaBookingDO row, boolean admin) { + Booking result = BeanUtils.toBean(row, Booking.class); + result.setDate(row.getDate().toString()); + LocalDateTime current = now(); + boolean future = row.getStartAt().isAfter(current); + boolean pending = "pending".equals(row.getStatus()); + boolean confirmed = "confirmed".equals(row.getStatus()); + if (pending && !future) result.setStatus("expired"); + result.setCanCancel(future && (pending || confirmed)); + result.setCanConfirm(admin && pending && future); + result.setCanReject(admin && pending && future); + result.setCanComplete(admin && confirmed && !row.getEndAt().isAfter(current)); + result.setCanAdminCancel(admin && (confirmed || (pending && future))); + return result; + } + + LocalDate bookingDate(String text) { + LocalDate day = parseDate(text), today = now().toLocalDate(); + if (day.isBefore(today) || day.isAfter(today.plusDays(6))) throw bad("仅支持预约今天起7天内的时段"); + return day; + } + + private LocalDate parseDate(String text) { + try { return LocalDate.parse(text); } + catch (DateTimeParseException | NullPointerException e) { throw bad("预约日期格式无效"); } + } + + private LocalTime time(String text) { + try { + if (text == null || !text.matches("([01][0-9]|2[0-3]):[0-5][0-9]")) throw bad("时间格式须为HH:mm"); + return LocalTime.parse(text); + } catch (DateTimeParseException e) { throw bad("时间格式无效"); } + } + + static boolean overlap(LocalDateTime start, LocalDateTime end, LocalDateTime otherStart, LocalDateTime otherEnd) { + return start.isBefore(otherEnd) && end.isAfter(otherStart); + } + + private void imageUrl(String url) { + if (url == null || !url.matches("(?i)^https?://[^\\s]+$")) throw bad("请上传有效的门店或包间图片"); + } + + public static ServiceException bad(String message) { return new ServiceException(1_004_050_000, message); } +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/api/user/MemberUserCandidateApiTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/api/user/MemberUserCandidateApiTest.java new file mode 100644 index 0000000..6162984 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/api/user/MemberUserCandidateApiTest.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.member.api.user; + +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.dal.dataobject.user.MemberUserDO; +import cn.iocoder.yudao.module.member.service.user.MemberUserService; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import java.util.Collections; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class MemberUserCandidateApiTest extends BaseMockitoUnitTest { + @InjectMocks private MemberUserApiImpl api; + @Mock private MemberUserService userService; + + @Test + void displayedAppIdIsSearchedAsUserCodeAndReturnedWithInternalId() { + when(userService.getUserPage(any())).thenReturn(new PageResult<>(Collections.singletonList( + new MemberUserDO().setId(300L).setUserCode("999827").setNickname("会员")), 1L)); + assertEquals(300L, api.searchUserPage("userCode", " 999827 ", new PageParam()).getList().get(0).getId()); + verify(userService).getUserPage(argThat(query -> "999827".equals(query.getUserCode()) + && query.getNickname() == null && query.getMobile() == null && query.getPageSize() == 10)); + } + + @Test + void unsupportedSearchFieldsNeverReachDatabase() { + assertThrows(IllegalArgumentException.class, () -> api.searchUserPage("id", "300", new PageParam())); + verifyNoInteractions(userService); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMessageModerationSecurityTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMessageModerationSecurityTest.java index a3f072c..d92ee77 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMessageModerationSecurityTest.java +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMessageModerationSecurityTest.java @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.member.controller.admin.chat; import cn.iocoder.yudao.framework.security.core.LoginUser; import cn.iocoder.yudao.module.member.client.service.ChatApi; import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivateMessageModerationReqVO; +import cn.iocoder.yudao.module.member.service.user.MemberUserService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.AnnotationConfigApplicationContext; @@ -76,6 +77,7 @@ class ChatPrivateMessageModerationSecurityTest { @EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true) static class Config { @Bean public ChatApi chatApi() { return mock(ChatApi.class); } + @Bean public MemberUserService memberUserService() { return mock(MemberUserService.class); } @Bean public ChatPrivateMsgController controller() { return new ChatPrivateMsgController(); } @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/chat/ChatPrivateMsgControllerTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMsgControllerTest.java index 93ecc60..17aa3b5 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMsgControllerTest.java +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMsgControllerTest.java @@ -1,9 +1,13 @@ package cn.iocoder.yudao.module.member.controller.admin.chat; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.member.client.constant.ChatConstant; import cn.iocoder.yudao.module.member.client.service.ChatApi; import cn.iocoder.yudao.module.member.client.vo.chat.ChatTableRespVO; import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.*; +import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO; +import cn.iocoder.yudao.module.member.service.user.MemberUserService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -12,6 +16,7 @@ import javax.validation.ValidatorFactory; import java.util.Arrays; import java.util.Collections; import java.util.Date; +import java.util.HashSet; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -19,19 +24,107 @@ import static org.mockito.Mockito.*; class ChatPrivateMsgControllerTest { + private ChatApi api; + private MemberUserService memberUserService; + private ChatPrivateMsgController controller; + + @BeforeEach + void setUp() { + api = mock(ChatApi.class); + memberUserService = mock(MemberUserService.class); + controller = new ChatPrivateMsgController(); + ReflectionTestUtils.setField(controller, "chatApi", api); + ReflectionTestUtils.setField(controller, "memberUserService", memberUserService); + } + @Test void emptyPagesPreserveTotalForMessagesAndConversations() { - ChatApi api = mock(ChatApi.class); when(api.privateMsgPage(any())).thenReturn(new ChatTableRespVO<>(Collections.emptyList(), 23)); when(api.privateConversationPage(any())).thenReturn(new ChatTableRespVO<>(Collections.emptyList(), 7)); - ChatPrivateMsgController controller = new ChatPrivateMsgController(); - ReflectionTestUtils.setField(controller, "chatApi", api); PageResult messages = controller.page(new ChatPrivateMessageReqVO()).getData(); assertTrue(messages.getList().isEmpty()); assertEquals(23L, messages.getTotal()); PageResult conversations = controller.conversationPage(new ChatPrivateConversationPageReqVO()).getData(); assertTrue(conversations.getList().isEmpty()); assertEquals(7L, conversations.getTotal()); + verifyNoInteractions(memberUserService); + } + + @Test + void messagePageUsesCurrentMemberNicknamesWithOneBatchLookup() { + ChatPrivateMessageRespVO first = message(300L, 465L); + ChatPrivateMessageRespVO second = message(465L, 300L); + when(api.privateMsgPage(any())).thenReturn(new ChatTableRespVO<>(Arrays.asList(first, second), 23)); + when(memberUserService.getUserList(anyCollection())).thenReturn(Arrays.asList( + MemberUserDO.builder().id(300L).userCode("999827").nickname("会员甲").build(), + MemberUserDO.builder().id(465L).userCode("300").nickname("会员乙").build())); + ChatPrivateMessageReqVO request = new ChatPrivateMessageReqVO(); + request.setPageNum(2); + request.setPageSize(10); + + PageResult result = controller.page(request).getData(); + + assertEquals(23L, result.getTotal()); + assertEquals(Arrays.asList(first, second), result.getList()); + assertEquals("会员甲", first.getSendUserName()); + assertEquals("会员乙", first.getRecvUserName()); + assertEquals("会员乙", second.getSendUserName()); + assertEquals("会员甲", second.getRecvUserName()); + verify(api).privateMsgPage(same(request)); + verify(memberUserService).getUserList(new HashSet<>(Arrays.asList(300L, 465L))); + verifyNoMoreInteractions(memberUserService); + } + + @Test + void missingOrBlankMemberNicknamesUseUserIdsAndSystemAccountsUseLocalNames() { + ChatPrivateMessageRespVO missing = message(300L, 465L); + ChatPrivateMessageRespVO system = message(ChatConstant.COM_MESSAGE_USER_ID, ChatConstant.COM_SERVICE_USER_ID); + ChatPrivateMessageRespVO unknown = message(null, 300L); + when(api.privateMsgPage(any())).thenReturn(new ChatTableRespVO<>(Arrays.asList(missing, system, unknown), 3)); + when(memberUserService.getUserList(anyCollection())).thenReturn(Collections.singletonList( + MemberUserDO.builder().id(300L).nickname(" ").build())); + + controller.page(new ChatPrivateMessageReqVO()); + + assertEquals("用户 300", missing.getSendUserName()); + assertEquals("用户 465", missing.getRecvUserName()); + assertEquals(ChatConstant.COM_MESSAGE_USER_NAME, system.getSendUserName()); + assertEquals(ChatConstant.COM_SERVICE_USER_NAME, system.getRecvUserName()); + assertEquals("未知用户", unknown.getSendUserName()); + verify(memberUserService).getUserList(new HashSet<>(Arrays.asList(300L, 465L, + ChatConstant.COM_MESSAGE_USER_ID, ChatConstant.COM_SERVICE_USER_ID))); + } + + @Test + void detailUsesMemberNicknamesAndSupportsDuplicateNicknames() { + ChatPrivateMessageRespVO message = message(300L, 465L); + when(api.privateMsgGetInfo(any())).thenReturn(message); + when(memberUserService.getUserList(anyCollection())).thenReturn(Arrays.asList( + MemberUserDO.builder().id(300L).nickname("同名会员").build(), + MemberUserDO.builder().id(465L).nickname("同名会员").build())); + + ChatPrivateMessageRespVO result = controller.getInfo(42L).getData(); + + assertSame(message, result); + assertEquals("同名会员", result.getSendUserName()); + assertEquals("同名会员", result.getRecvUserName()); + verify(api).privateMsgGetInfo(argThat(request -> request.getMsgId().equals(42L))); + verify(memberUserService).getUserList(new HashSet<>(Arrays.asList(300L, 465L))); + } + + @Test + void missingDetailSkipsMemberLookup() { + assertNull(controller.getInfo(42L).getData()); + verifyNoInteractions(memberUserService); + } + + private ChatPrivateMessageRespVO message(Long sendId, Long recvId) { + ChatPrivateMessageRespVO message = new ChatPrivateMessageRespVO(); + message.setSendId(sendId); + message.setRecvId(recvId); + message.setSendUserName("im-sender"); + message.setRecvUserName("im-receiver"); + return message; } @Test diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/location/LocationServiceImplTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/location/LocationServiceImplTest.java new file mode 100644 index 0000000..5c7e40b --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/location/LocationServiceImplTest.java @@ -0,0 +1,97 @@ +package cn.iocoder.yudao.module.member.service.location; + +import cn.hutool.core.bean.BeanUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.member.client.service.TxMapApi; +import cn.iocoder.yudao.module.member.client.vo.txmap.geocoder.TxMapGeocoderReqVO; +import cn.iocoder.yudao.module.member.client.vo.txmap.geocoder.TxMapGeocoderRespVO; +import cn.iocoder.yudao.module.member.controller.admin.location.vo.LocationSaveReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.location.LocationDO; +import cn.iocoder.yudao.module.member.dal.mysql.location.LocationMapper; +import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class LocationServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private LocationServiceImpl service; + @Mock + private LocationMapper locationMapper; + @Mock + private MemberUserMapper memberUserMapper; + @Mock + private TxMapApi txMapApi; + + @Test + void systemGpsCoordinatesDeclareTheirCoordinateTypeAndFillTheCity() { + LocationSaveReqVO request = request("wgs84"); + TxMapGeocoderRespVO.AddressComponent address = new TxMapGeocoderRespVO.AddressComponent(); + address.setProvince("浙江省"); + address.setCity("杭州市"); + address.setDistrict("上城区"); + TxMapGeocoderRespVO response = new TxMapGeocoderRespVO(); + response.setAddress_component(address); + when(txMapApi.geocoder(any())).thenReturn(response); + + service.createLocation(request); + + ArgumentCaptor geocode = ArgumentCaptor.forClass(TxMapGeocoderReqVO.class); + verify(txMapApi).geocoder(geocode.capture()); + assertThat(geocode.getValue().getCoord_type()).isEqualTo(1); + assertThat(BeanUtil.beanToMap(geocode.getValue())).containsEntry("coord_type", 1); + assertThat(geocode.getValue().getLocation()).isEqualTo("30.27,120.15"); + ArgumentCaptor location = ArgumentCaptor.forClass(LocationDO.class); + verify(locationMapper).insert(location.capture()); + assertThat(location.getValue().getCity()).isEqualTo("杭州市"); + assertThat(location.getValue().getLon()).isEqualTo(120.15); + assertThat(location.getValue().getLat()).isEqualTo(30.27); + } + + @Test + void legacyRequestsKeepTheExistingMapCoordinateDefault() { + service.createLocation(request(null)); + + ArgumentCaptor geocode = ArgumentCaptor.forClass(TxMapGeocoderReqVO.class); + verify(txMapApi).geocoder(geocode.capture()); + assertThat(geocode.getValue().getCoord_type()).isNull(); + } + + @Test + void gcj02RequestsUseTheMapDefault() { + service.createLocation(request("gcj02")); + + ArgumentCaptor geocode = ArgumentCaptor.forClass(TxMapGeocoderReqVO.class); + verify(txMapApi).geocoder(geocode.capture()); + assertThat(geocode.getValue().getCoord_type()).isNull(); + } + + @Test + void aCompleteNativeAddressDoesNotNeedServerGeocoding() { + LocationSaveReqVO request = request("wgs84"); + request.setProvince("浙江省"); + request.setCity("杭州市"); + request.setDistrict("上城区"); + + service.createLocation(request); + + verifyNoInteractions(txMapApi); + } + + private LocationSaveReqVO request(String coordinateType) { + LocationSaveReqVO request = new LocationSaveReqVO(); + request.setMemberId(7L); + request.setLat(30.27); + request.setLon(120.15); + request.setCoordinateType(coordinateType); + return request; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeUserBrokerageTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeUserBrokerageTest.java index d46311d..ee2efdd 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeUserBrokerageTest.java +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeUserBrokerageTest.java @@ -32,6 +32,15 @@ class OpenTypeUserBrokerageTest extends BaseMockitoUnitTest { @Mock private MemberUserService memberUserService; + @Test + void alreadyPaidMembershipNeverRecalculatesCommissionOnDuplicateCallback() { + when(mapper.selectById(100L)).thenReturn(new cn.iocoder.yudao.module.member.dal.dataobject.opentype.OpenTypeUserDO() + .setId(100L).setMemberId(300L).setPayStatus(true).setPayOrderId(900L)); + service.updateWalletRechargerPaid(100L, 900L); + service.updateWalletRechargerPaid(100L, 900L); + verifyNoInteractions(brokerageApi, memberUserService); + } + @Test void pageUsesStoredOrderRecipientsAndBatchesProfilesIncludingMissingMembers() { OpenTypeUserRespVO paid = record(100L); diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/push/MemberPushModeTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/push/MemberPushModeTest.java new file mode 100644 index 0000000..b3d3af8 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/push/MemberPushModeTest.java @@ -0,0 +1,91 @@ +package cn.iocoder.yudao.module.member.service.push; + +import cn.iocoder.yudao.module.member.controller.tea.vo.TeaModels.Setting; +import cn.iocoder.yudao.module.member.service.tea.TeaRoomService; +import cn.iocoder.yudao.module.member.client.service.ChatApi; +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.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.*; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** 确保后台切模式在客户端不启动时也阻断新发送和旧 CID 重试。 */ +class MemberPushModeTest { + private MemberPushTaskService service; + private MemberPushRecordMapper records; + private MemberPushDeviceMapper devices; + private MemberUserMapper users; + private JPushGateway gateway; + private ChatApi chat; + private Setting setting; + + @BeforeEach void setUp() { + for (Class type : Arrays.asList(MemberPushRecordDO.class, MemberPushDeviceDO.class, MemberUserDO.class)) + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), type); + service = new MemberPushTaskService(); + records = mock(MemberPushRecordMapper.class); devices = mock(MemberPushDeviceMapper.class); + users = mock(MemberUserMapper.class); gateway = mock(JPushGateway.class); chat = mock(ChatApi.class); + TeaRoomService modes = mock(TeaRoomService.class); + setting = new Setting(); setting.setIosMode("tea"); + when(modes.getSetting(null)).thenAnswer(invocation -> setting); + when(modes.mode(any(), anyString())).thenCallRealMethod(); + when(gateway.getAppKey()).thenReturn("test-app"); + ReflectionTestUtils.setField(service, "recordMapper", records); + ReflectionTestUtils.setField(service, "deviceMapper", devices); + ReflectionTestUtils.setField(service, "userMapper", users); + ReflectionTestUtils.setField(service, "gateway", gateway); + ReflectionTestUtils.setField(service, "chatApi", chat); + ReflectionTestUtils.setField(service, "teaRoomService", modes); + } + + @Test void teaBlocksManualAndAutomaticBeforeCallingIm() { + for (String source : Arrays.asList("MANUAL", "AUTO")) { + MemberPushRecordDO record = record("ios"); + List result = ReflectionTestUtils.invokeMethod(service, "filterEligible", task(source), Collections.singletonList(record)); + assertTrue(result.isEmpty()); assertEquals("SKIPPED", record.getStatus()); assertEquals("APP_MODE_TEA", record.getErrorCode()); + } + verifyNoInteractions(chat); + verify(gateway, never()).send(anyString()); + } + + @Test void androidRemainsEligibleWhenOnlyIosIsTea() { + MemberPushRecordDO ios = record("ios"), android = record("android"); android.setDeviceId(2L); android.setId(2L); + MemberPushDeviceDO device = MemberPushDeviceDO.builder().id(2L).memberId(5L).platform("android") + .registrationId("registration-android").provider("jpush").appKey("test-app").build(); + when(devices.selectList(any())).thenReturn(Collections.singletonList(device)); + when(users.selectBatchIds(anyCollection())).thenReturn(Collections.singletonList(MemberUserDO.builder().id(5L).status(0).build())); + List result = ReflectionTestUtils.invokeMethod(service, "filterEligible", task("MANUAL"), Arrays.asList(ios, android)); + assertEquals(Collections.singletonList(android), result); + assertEquals("APP_MODE_TEA", ios.getErrorCode()); + } + + @Test void oldUncertainBatchStopsRetryAfterModeChanges() { + MemberPushRecordDO record = record("ios"); record.setCid("old-cid"); record.setStatus("SENDING"); + when(records.selectList(any())).thenReturn(Collections.singletonList(record)); + ReflectionTestUtils.invokeMethod(service, "resumeBatch", task("AUTO"), "old-cid"); + verify(records).update(isNull(), any()); + verifyNoInteractions(chat, devices, users); + verify(gateway, never()).send(anyString()); + } + + @Test void modeIsCheckedAgainImmediatelyBeforeGatewaySubmission() { + ReflectionTestUtils.invokeMethod(service, "submitBatch", task("MANUAL"), Collections.singletonList(record("ios")), "cid", "payload"); + verify(records).update(isNull(), any()); + verify(gateway, never()).send(anyString()); + } + + private MemberPushRecordDO record(String platform) { + MemberPushRecordDO row = new MemberPushRecordDO(); row.setId(1L); row.setDeviceId(1L); row.setMemberId(5L); + row.setPlatform(platform); row.setRegistrationId("registration-" + platform); row.setStatus("WAITING"); return row; + } + private MemberPushTaskDO task(String source) { MemberPushTaskDO task = new MemberPushTaskDO(); task.setId(1L); task.setSource(source); return task; } +} + 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 index 8cd691c..e14c1ec 100644 --- 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 @@ -679,6 +679,11 @@ class MemberPushTaskServiceMySqlTest { ReflectionTestUtils.setField(result, "gateway", gateway); ReflectionTestUtils.setField(result, "redisson", mock(RedissonClient.class)); ReflectionTestUtils.setField(result, "transactions", new TransactionTemplate(transactionManager)); + cn.iocoder.yudao.module.member.service.tea.TeaRoomService modeService = + mock(cn.iocoder.yudao.module.member.service.tea.TeaRoomService.class); + when(modeService.getSetting(null)).thenReturn(new cn.iocoder.yudao.module.member.controller.tea.vo.TeaModels.Setting()); + when(modeService.mode(any(), anyString())).thenReturn("chat"); + ReflectionTestUtils.setField(result, "teaRoomService", modeService); return result; } diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/tea/AccountClosureServiceTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/tea/AccountClosureServiceTest.java new file mode 100644 index 0000000..01f683f --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/tea/AccountClosureServiceTest.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.member.service.tea; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.member.controller.tea.vo.AccountClosureModels.*; +import cn.iocoder.yudao.module.member.dal.dataobject.tea.MemberAccountClosureDO; +import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO; +import cn.iocoder.yudao.module.member.dal.mysql.tea.MemberAccountClosureMapper; +import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.*; +import org.mockito.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class AccountClosureServiceTest extends BaseMockitoUnitTest { + @InjectMocks private AccountClosureService service; + @Mock private MemberAccountClosureMapper mapper; + @Mock private MemberUserMapper userMapper; + @BeforeEach void metadata() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), MemberAccountClosureDO.class); + } + @Test void repeatedApplicationReturnsExistingWithoutMutatingAccount() { + when(userMapper.selectForChatSync(5L)).thenReturn(MemberUserDO.builder().id(5L).status(0).build()); + MemberAccountClosureDO row = new MemberAccountClosureDO(); row.setId(9L); row.setStatus("processing"); + when(mapper.selectList(any())).thenReturn(Collections.singletonList(row)); + assertEquals(9L, service.create(5L, new Create())); + verify(mapper, never()).insert(any(MemberAccountClosureDO.class)); + verify(userMapper, never()).updateById(any(MemberUserDO.class)); + verify(userMapper, never()).deleteById(any(java.io.Serializable.class)); + } + @Test void resolvedOnlyRecordsHumanOutcome() { + MemberAccountClosureDO row = new MemberAccountClosureDO(); row.setId(9L); row.setStatus("pending"); + when(mapper.selectForUpdate(9L)).thenReturn(row); + Update request = new Update(); request.setId(9L); request.setStatus("resolved"); request.setProcessRemark("已人工联系并核实处理"); + service.update(request); + assertEquals("resolved", row.getStatus()); + verify(mapper).updateById(row); + verifyNoInteractions(userMapper); + assertThrows(ServiceException.class, () -> service.update(request)); + } +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/tea/TeaControllerSecurityTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/tea/TeaControllerSecurityTest.java new file mode 100644 index 0000000..ff65b5f --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/tea/TeaControllerSecurityTest.java @@ -0,0 +1,113 @@ +package cn.iocoder.yudao.module.member.service.tea; + +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.module.member.controller.admin.tea.*; +import cn.iocoder.yudao.module.member.controller.app.tea.*; +import cn.iocoder.yudao.module.member.controller.tea.vo.TeaModels.*; +import cn.iocoder.yudao.module.member.controller.tea.vo.AccountClosureModels; +import org.junit.jupiter.api.*; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.*; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.*; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.ValidatorFactory; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class TeaControllerSecurityTest { + private AnnotationConfigApplicationContext context; + private TeaRoomService tea; + private AccountClosureService closure; + @BeforeEach void setUp() { + context = new AnnotationConfigApplicationContext(); + tea = mock(TeaRoomService.class); closure = mock(AccountClosureService.class); + context.getBeanFactory().registerSingleton("teaRoomService", tea); + context.getBeanFactory().registerSingleton("accountClosureService", closure); + context.register(Config.class); context.refresh(); + } + @AfterEach void close() { SecurityContextHolder.clearContext(); context.close(); } + + @Test void anonymousCannotReadOrMutatePersonalBookingsAndClosureRequests() { + AppTeaBookingController bookings = context.getBean(AppTeaBookingController.class); + AppAccountClosureController closures = context.getBean(AppAccountClosureController.class); + SecurityContextHolder.getContext().setAuthentication(new AnonymousAuthenticationToken("test", "anonymous", + Collections.singletonList(new org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_ANONYMOUS")))); + assertThrows(AccessDeniedException.class, () -> bookings.page(new BookingPage())); + assertThrows(AccessDeniedException.class, () -> bookings.create(new BookingCreate())); + assertThrows(AccessDeniedException.class, closures::latest); + assertThrows(AccessDeniedException.class, () -> closures.create(new AccountClosureModels.Create())); + verifyNoInteractions(tea, closure); + } + + @Test void appAlwaysUsesAuthenticatedMemberIdentity() { + login(); + BookingCreate request = new BookingCreate(); + when(tea.createBooking(42L, request)).thenReturn(7L); + assertEquals(7L, context.getBean(AppTeaBookingController.class).create(request).getData()); + context.getBean(AppTeaBookingController.class).get(7L); + context.getBean(AppAccountClosureController.class).latest(); + verify(tea).booking(7L, 42L); + verify(closure).latest(42L); + } + + @Test void adminQueryConfigurationAndBookingPermissionsStayIndependent() { + login(); + Permission permissions = context.getBean(Permission.class); + MemberAppSettingController settings = context.getBean(MemberAppSettingController.class); + MemberTeaRoomController rooms = context.getBean(MemberTeaRoomController.class); + MemberTeaBookingController bookings = context.getBean(MemberTeaBookingController.class); + MemberAccountClosureController closures = context.getBean(MemberAccountClosureController.class); + assertThrows(AccessDeniedException.class, settings::get); + assertThrows(AccessDeniedException.class, rooms::list); + assertThrows(AccessDeniedException.class, () -> bookings.update(new StatusUpdate())); + assertThrows(AccessDeniedException.class, () -> closures.update(new AccountClosureModels.Update())); + verifyNoInteractions(tea, closure); + permissions.allowed.add("app:setting:query"); + settings.get(); rooms.list(); bookings.get(1L); + assertThrows(AccessDeniedException.class, () -> settings.save(new Setting())); + assertThrows(AccessDeniedException.class, () -> bookings.update(new StatusUpdate())); + permissions.allowed.add("app:setting:update"); + settings.save(new Setting()); + assertThrows(AccessDeniedException.class, () -> closures.update(new AccountClosureModels.Update())); + permissions.allowed.add("app:setting:booking"); + bookings.update(new StatusUpdate()); + closures.update(new AccountClosureModels.Update()); + } + + @Test void inputValidationRejectsMalformedModesSlotsAndAmounts() { + try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) { + Validator validator = factory.getValidator(); + Setting setting = new Setting(); + assertTrue(validator.validate(setting).isEmpty()); + setting.setIosMode("unknown"); setting.setSlots(Collections.singletonList(null)); + assertEquals(2, validator.validate(setting).size()); + Room room = new Room(); room.setName("测试"); room.setCover("https://example.test/test.jpg"); room.setPrice(-1); room.setCapacity(0); + assertEquals(2, validator.validate(room).size()); + } + } + + private void login() { + LoginUser login = new LoginUser(); login.setId(42L); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(login, null, Collections.emptyList())); + } + public static class Permission { + final Set allowed = new HashSet<>(); + public boolean hasPermission(String permission) { return allowed.contains(permission); } + } + @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true) + static class Config { + @Bean(name = "ss") public Permission permissions() { return new Permission(); } + @Bean public MemberAppSettingController settings() { return new MemberAppSettingController(); } + @Bean public MemberTeaRoomController rooms() { return new MemberTeaRoomController(); } + @Bean public MemberTeaBookingController bookings() { return new MemberTeaBookingController(); } + @Bean public MemberAccountClosureController closures() { return new MemberAccountClosureController(); } + @Bean public AppTeaBookingController appBookings() { return new AppTeaBookingController(); } + @Bean public AppAccountClosureController appClosures() { return new AppAccountClosureController(); } + } +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/tea/TeaRoomConcurrencyTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/tea/TeaRoomConcurrencyTest.java new file mode 100644 index 0000000..62f2a74 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/tea/TeaRoomConcurrencyTest.java @@ -0,0 +1,168 @@ +package cn.iocoder.yudao.module.member.service.tea; + +import cn.hutool.json.JSONUtil; +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.controller.tea.vo.TeaModels.*; +import cn.iocoder.yudao.module.member.dal.mysql.tea.*; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.core.*; +import com.baomidou.mybatisplus.core.config.GlobalConfig; +import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import org.apache.ibatis.mapping.Environment; +import org.junit.jupiter.api.*; +import org.mybatis.spring.SqlSessionTemplate; +import org.mybatis.spring.transaction.SpringManagedTransactionFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +/** 在隔离内存数据库运行真实 Mapper/行锁/事务,外部服务和业务数据库均不参与。 */ +class TeaRoomConcurrencyTest { + private TeaRoomService service; + private TransactionTemplate transactions; + private JdbcTemplate jdbc; + private Long roomId; + private DriverManagerDataSource dataSource; + + @BeforeEach void setUp() { + dataSource = new DriverManagerDataSource("jdbc:h2:mem:tea_" + UUID.randomUUID().toString().replace("-", "") + + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=8000;DATABASE_TO_LOWER=TRUE", "sa", ""); + jdbc = new JdbcTemplate(dataSource); + String audit = ",creator varchar(64) default '',create_time timestamp default current_timestamp," + + "updater varchar(64) default '',update_time timestamp default current_timestamp," + + "deleted boolean default false,tenant_id bigint default 0"; + jdbc.execute("create table member_app_setting(id bigint auto_increment primary key,config_key varchar(32),config_json text" + + audit + ",unique(tenant_id,config_key,deleted))"); + jdbc.execute("create table member_tea_room(id bigint auto_increment primary key,name varchar(80),cover varchar(2048)," + + "capacity int,facilities text,description varchar(2000),price int,sort int,enabled boolean" + audit + ")"); + jdbc.execute("create table member_tea_booking(id bigint auto_increment primary key,booking_no varchar(40),member_id bigint," + + "room_id bigint,room_name varchar(80),room_cover varchar(2048),store_name varchar(80),store_address varchar(300),store_phone varchar(30)," + + "date date,start_time varchar(5),end_time varchar(5),start_at timestamp,end_at timestamp,persons int," + + "contact_name varchar(40),contact_phone varchar(30),remark varchar(500),price int,status varchar(16),reason varchar(500)," + + "request_id varchar(64),request_hash varchar(64)" + audit + ",unique(tenant_id,member_id,request_id))"); + MybatisConfiguration configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.setEnvironment(new Environment("tea-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()))); + configuration.addInterceptor(interceptor); + configuration.addMapper(MemberAppSettingMapper.class); + configuration.addMapper(MemberTeaRoomMapper.class); + configuration.addMapper(MemberTeaBookingMapper.class); + SqlSessionTemplate sessions = new SqlSessionTemplate(new MybatisSqlSessionFactoryBuilder().build(configuration)); + service = new TeaRoomService(); + ReflectionTestUtils.setField(service, "settingMapper", sessions.getMapper(MemberAppSettingMapper.class)); + ReflectionTestUtils.setField(service, "roomMapper", sessions.getMapper(MemberTeaRoomMapper.class)); + ReflectionTestUtils.setField(service, "bookingMapper", sessions.getMapper(MemberTeaBookingMapper.class)); + ReflectionTestUtils.setField(service, "clock", Clock.fixed(Instant.parse("2026-11-12T01:00:00Z"), TeaRoomService.ZONE)); + transactions = new TransactionTemplate(new DataSourceTransactionManager(dataSource)); + TenantContextHolder.setTenantId(1L); + Room room = new Room(); room.setName("原包间"); room.setCover("https://example.test/room.jpg"); + room.setCapacity(4); room.setPrice(8800); + roomId = transactions.execute(status -> service.saveRoom(room, true)); + Setting setting = TeaRoomServiceTest.completeSetting(); + setting.setIosMode("tea"); + transactions.executeWithoutResult(status -> service.saveSetting(setting)); + } + + @AfterEach void tearDown() { + TenantContextHolder.clear(); + jdbc.execute("DROP ALL OBJECTS"); + } + + @Test void pendingDoesNotHoldSlotAndConcurrentConfirmationCannotDoubleBook() throws Exception { + Long first = create(11L, "first"), second = create(12L, "second"); + assertTrue(service.slots(roomId, "2026-11-13").get(0).getAvailable()); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + List> outcomes = new ArrayList<>(); + for (Long id : Arrays.asList(first, second)) outcomes.add(executor.submit(() -> { + TenantContextHolder.setTenantId(1L); + try { + start.await(5, TimeUnit.SECONDS); + StatusUpdate request = new StatusUpdate(); request.setId(id); request.setStatus("confirmed"); + transactions.executeWithoutResult(status -> service.updateStatus(request)); + return true; + } catch (ServiceException expectedConflict) { + return false; + } finally { TenantContextHolder.clear(); } + })); + start.countDown(); + int success = 0; + for (Future outcome : outcomes) if (outcome.get(15, TimeUnit.SECONDS)) success++; + assertEquals(1, success); + assertEquals(1, jdbc.queryForObject("select count(*) from member_tea_booking where status='confirmed'", Integer.class)); + assertFalse(service.slots(roomId, "2026-11-13").get(0).getAvailable()); + } finally { executor.shutdownNow(); } + } + + @Test void duplicateSubmissionAndSnapshotsSurviveEditsAndCancellation() { + Long id = create(11L, "retry"); + assertEquals(id, create(11L, "retry")); + assertEquals(id, create(11L, "another-request")); + assertEquals(1, jdbc.queryForObject("select count(*) from member_tea_booking", Integer.class)); + Room edited = service.room(roomId, false); edited.setName("新包间"); edited.setPrice(18800); + transactions.executeWithoutResult(status -> service.saveRoom(edited, false)); + Booking original = service.booking(id, 11L); + assertEquals("原包间", original.getRoomName()); assertEquals(8800, original.getPrice()); + assertEquals("2026-11-13", original.getDate()); + StatusUpdate confirm = new StatusUpdate(); confirm.setId(id); confirm.setStatus("confirmed"); + transactions.executeWithoutResult(status -> service.updateStatus(confirm)); + transactions.executeWithoutResult(status -> service.cancel(id, 11L)); + assertTrue(service.slots(roomId, "2026-11-13").get(0).getAvailable()); + Long replacement = create(11L, "new-after-cancel"); + assertNotEquals(id, replacement); assertEquals(18800, service.booking(replacement, 11L).getPrice()); + } + + @Test void tenantPluginKeepsOtherTenantFromReadingOrChangingBookings() { + Long id = create(11L, "tenant"); + TenantContextHolder.setTenantId(2L); + assertEquals("chat", service.getSetting("ios").getCurrentMode()); + assertTrue(service.rooms(false).isEmpty()); + assertThrows(ServiceException.class, () -> service.booking(id, null)); + assertThrows(ServiceException.class, () -> transactions.executeWithoutResult(status -> service.cancel(id, 11L))); + } + + @Test void disabledRoomCannotBeConfirmedAfterApplication() { + Long id = create(11L, "disable"); + transactions.executeWithoutResult(status -> service.disableRoom(roomId)); + StatusUpdate confirm = new StatusUpdate(); confirm.setId(id); confirm.setStatus("confirmed"); + ServiceException error = assertThrows(ServiceException.class, + () -> transactions.executeWithoutResult(status -> service.updateStatus(confirm))); + assertTrue(error.getMessage().contains("下架")); + assertEquals("pending", service.booking(id, 11L).getStatus()); + } + + @Test void changedSlotCannotBeConfirmedButOriginalSnapshotStaysVisible() { + Long id = create(11L, "changed-slot"); + Setting settings = service.getSetting(null); + settings.setSlots(Collections.singletonList(TeaRoomServiceTest.slot("12:00", "14:00"))); + transactions.executeWithoutResult(status -> service.saveSetting(settings)); + StatusUpdate confirm = new StatusUpdate(); confirm.setId(id); confirm.setStatus("confirmed"); + assertThrows(ServiceException.class, () -> transactions.executeWithoutResult(status -> service.updateStatus(confirm))); + assertEquals("10:00", service.booking(id, 11L).getStartTime()); + } + + private Long create(Long memberId, String requestId) { + BookingCreate request = new BookingCreate(); request.setRoomId(roomId); request.setDate("2026-11-13"); + request.setStartTime("10:00"); request.setEndTime("12:00"); request.setPersons(2); + request.setContactName("测试顾客"); request.setContactPhone("13800000000"); request.setRequestId(requestId); + return transactions.execute(status -> service.createBooking(memberId, request)); + } +} + diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/tea/TeaRoomServiceTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/tea/TeaRoomServiceTest.java new file mode 100644 index 0000000..c136091 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/tea/TeaRoomServiceTest.java @@ -0,0 +1,156 @@ +package cn.iocoder.yudao.module.member.service.tea; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.member.controller.tea.vo.TeaModels.*; +import cn.iocoder.yudao.module.member.dal.dataobject.tea.*; +import cn.iocoder.yudao.module.member.dal.mysql.tea.*; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.*; +import org.mockito.*; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class TeaRoomServiceTest extends BaseMockitoUnitTest { + @InjectMocks private TeaRoomService service; + @Mock private MemberAppSettingMapper settingMapper; + @Mock private MemberTeaRoomMapper roomMapper; + @Mock private MemberTeaBookingMapper bookingMapper; + + @BeforeEach void setUp() { + for (Class type : Arrays.asList(MemberAppSettingDO.class, MemberTeaRoomDO.class, MemberTeaBookingDO.class)) + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), type); + ReflectionTestUtils.setField(service, "clock", Clock.fixed(Instant.parse("2026-11-12T01:00:00Z"), TeaRoomService.ZONE)); + } + + @Test void missingSettingsKeepEachPlatformInChat() { + assertEquals("chat", service.getSetting("ios").getCurrentMode()); + assertEquals("chat", service.getSetting("android").getCurrentMode()); + assertEquals("chat", service.getSetting("h5").getCurrentMode()); + assertThrows(ServiceException.class, () -> service.getSetting("unknown")); + } + + @Test void chinaCalendarLimitsTodayThroughSixDaysAhead() { + assertEquals(LocalDate.of(2026, 11, 18), service.bookingDate("2026-11-18")); + assertThrows(ServiceException.class, () -> service.bookingDate("2026-11-19")); + assertThrows(ServiceException.class, () -> service.bookingDate("2026-11-11")); + assertThrows(ServiceException.class, () -> service.bookingDate("2026-02-30")); + } + + @Test void settingsRejectOverlappingSlotsButAllowAdjacentSlots() { + Setting setting = new Setting(); + setting.setSlots(Arrays.asList(slot("10:00", "12:00"), slot("11:00", "13:00"))); + assertThrows(ServiceException.class, () -> service.validateSetting(setting)); + setting.setSlots(Arrays.asList(slot("12:00", "14:00"), slot("10:00", "12:00"))); + assertDoesNotThrow(() -> service.validateSetting(setting)); + assertEquals("10:00", setting.getSlots().get(0).getStartTime()); + } + + @Test void teaActivationRequiresRealStoreAndEnabledRoom() { + Setting setting = completeSetting(); + setting.setIosMode("tea"); + when(roomMapper.selectCount(any())).thenReturn(0L); + assertThrows(ServiceException.class, () -> service.validateSetting(setting)); + when(roomMapper.selectCount(any())).thenReturn(1L); + assertDoesNotThrow(() -> service.validateSetting(setting)); + setting.setStoreName(""); + assertThrows(ServiceException.class, () -> service.validateSetting(setting)); + } + + @Test void expiredPendingIsReadOnlyAndDoesNotBecomeConfirmed() { + MemberTeaBookingDO row = booking("pending"); + row.setStartAt(LocalDateTime.of(2026,11,12,8,0)); + Booking result = service.response(row, true); + assertEquals("expired", result.getStatus()); + assertFalse(result.getCanConfirm()); + assertFalse(result.getCanCancel()); + assertFalse(result.getCanAdminCancel()); + assertEquals("pending", row.getStatus()); + } + + @Test void appointmentOwnershipCannotBeOverridden() { + when(bookingMapper.selectById(8L)).thenReturn(booking("pending")); + assertThrows(ServiceException.class, () -> service.booking(8L, 999L)); + assertThrows(ServiceException.class, () -> service.cancel(8L, 999L)); + verifyNoInteractions(roomMapper); + } + + @Test void confirmedAppointmentCannotCompleteBeforeEnd() { + MemberTeaBookingDO row = booking("confirmed"); + when(bookingMapper.selectById(8L)).thenReturn(row); + when(bookingMapper.selectForUpdate(8L)).thenReturn(row); + when(roomMapper.selectForUpdate(2L)).thenReturn(new MemberTeaRoomDO()); + StatusUpdate request = new StatusUpdate(); + request.setId(8L); request.setStatus("completed"); + assertThrows(ServiceException.class, () -> service.updateStatus(request)); + verify(bookingMapper, never()).updateById(any(MemberTeaBookingDO.class)); + } + + @Test void confirmedCollisionRejectsAnotherPendingRequest() { + MemberTeaBookingDO row = booking("pending"); + when(bookingMapper.selectById(8L)).thenReturn(row); + when(bookingMapper.selectForUpdate(8L)).thenReturn(row); + MemberTeaRoomDO room = new MemberTeaRoomDO(); room.setEnabled(true); room.setCapacity(4); + row.setPersons(2); + when(roomMapper.selectForUpdate(2L)).thenReturn(room); + MemberAppSettingDO setting = new MemberAppSettingDO(); + setting.setConfigJson(cn.hutool.json.JSONUtil.toJsonStr(completeSetting())); + when(settingMapper.selectCurrentForUpdate()).thenReturn(setting); + when(bookingMapper.selectList(any())).thenReturn(Collections.singletonList(booking("confirmed"))); + StatusUpdate request = new StatusUpdate(); + request.setId(8L); request.setStatus("confirmed"); + assertThrows(ServiceException.class, () -> service.updateStatus(request)); + verify(bookingMapper, never()).updateById(any(MemberTeaBookingDO.class)); + } + + @Test void cancellingDoesNotChangeSnapshotsAndIsIdempotent() { + MemberTeaBookingDO row = booking("confirmed"); + row.setRoomName("提交时包间"); row.setPrice(8800); + when(bookingMapper.selectById(8L)).thenReturn(row); + when(bookingMapper.selectForUpdate(8L)).thenReturn(row); + when(roomMapper.selectForUpdate(2L)).thenReturn(new MemberTeaRoomDO()); + service.cancel(8L, 5L); + assertEquals("cancelled", row.getStatus()); + assertEquals("提交时包间", row.getRoomName()); + assertEquals(8800, row.getPrice()); + service.cancel(8L, 5L); + verify(bookingMapper, times(1)).updateById(row); + } + + @Test void adjoiningSlotsAreNotConflicts() { + LocalDateTime start = LocalDateTime.of(2026,11,13,10,0); + assertFalse(TeaRoomService.overlap(start, start.plusHours(2), start.plusHours(2), start.plusHours(4))); + assertTrue(TeaRoomService.overlap(start, start.plusHours(2), start.plusHours(1), start.plusHours(3))); + } + + @Test void blankRoomCoverUsesPlaceholderButNonemptyUrlMustBeValid() { + Room request = new Room(); request.setName("测试包间"); request.setCover(" "); + request.setCapacity(4); request.setPrice(8800); + service.saveRoom(request, true); + ArgumentCaptor saved = ArgumentCaptor.forClass(MemberTeaRoomDO.class); + verify(roomMapper).insert(saved.capture()); + assertEquals("", saved.getValue().getCover()); + request.setCover("javascript:alert(1)"); + assertThrows(ServiceException.class, () -> service.saveRoom(request, true)); + } + + + static Slot slot(String start, String end) { Slot slot = new Slot(); slot.setStartTime(start); slot.setEndTime(end); return slot; } + static Setting completeSetting() { + Setting s = new Setting(); s.setStoreName("测试门店"); s.setStoreCover("https://example.test/store.jpg"); + s.setStoreAddress("测试地址"); s.setStorePhone("13800000000"); s.setSlots(Collections.singletonList(slot("10:00","12:00"))); + return s; + } + static MemberTeaBookingDO booking(String status) { + MemberTeaBookingDO b = new MemberTeaBookingDO(); b.setId(8L); b.setRoomId(2L); b.setMemberId(5L); + b.setDate(LocalDate.of(2026,11,13)); b.setStartAt(b.getDate().atTime(10,0)); b.setEndAt(b.getDate().atTime(12,0)); + b.setStartTime("10:00"); b.setEndTime("12:00"); b.setStatus(status); return b; + } +} +