feat:增加伪装方便ios上架
This commit is contained in:
1 parent
fc36d1b17a
commit
f3acddd021
70 files changed
+3282
-191
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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='永久充值首奖发放凭据,退款或删流水不恢复资格';
|
||||
@@ -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;
|
||||
+5
@@ -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, "佣金提现记录不存在");
|
||||
|
||||
+45
-2
@@ -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<Boolean> 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<Boolean> 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<BrokerageUserBindContextRespVO> 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<PageResult<BrokerageBindMemberRespVO>> 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<PageResult<BrokerageBindLogRespVO>> getBindLogPage(@RequestParam("id") Long id,
|
||||
@Valid PageParam page) {
|
||||
PageResult<BrokerageBindLogDO> logs = brokerageUserService.getBindLogPage(id, page);
|
||||
Set<Long> ids = new HashSet<>();
|
||||
logs.getList().forEach(log -> {
|
||||
if (log.getOldBindUserId() != null) {
|
||||
ids.add(log.getOldBindUserId());
|
||||
}
|
||||
if (log.getNewBindUserId() != null) {
|
||||
ids.add(log.getNewBindUserId());
|
||||
}
|
||||
});
|
||||
Map<Long, MemberUserRespDTO> members = memberUserApi.getUserMap(ids);
|
||||
PageResult<BrokerageBindLogRespVO> 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')")
|
||||
|
||||
+19
@@ -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;
|
||||
}
|
||||
+12
@@ -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;
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
+1
-7
@@ -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 {
|
||||
}
|
||||
+3
-5
@@ -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;
|
||||
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+3
@@ -47,6 +47,9 @@ public class BrokerageRecordDO extends BaseDO {
|
||||
*/
|
||||
private Integer bizType;
|
||||
|
||||
/** 资金来源命名空间,区分共用 ORDER 类型的充值和商城订单项。 */
|
||||
private String bizScene;
|
||||
|
||||
/**
|
||||
* 标题
|
||||
*/
|
||||
|
||||
+3
@@ -42,6 +42,9 @@ public class BrokerageUserDO extends BaseDO {
|
||||
*/
|
||||
private LocalDateTime bindUserTime;
|
||||
|
||||
/** 仅在关系实际变化时递增,防止陈旧页面覆盖及 A→B→A。 */
|
||||
private Long bindVersion;
|
||||
|
||||
/**
|
||||
* 是否有分销资格
|
||||
*/
|
||||
|
||||
+21
@@ -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<BrokerageBindLogDO> {
|
||||
default BrokerageBindLogDO selectByRequestId(String requestId) {
|
||||
return selectOne(new LambdaQueryWrapperX<BrokerageBindLogDO>()
|
||||
.eq(BrokerageBindLogDO::getRequestId, requestId).last("FOR UPDATE"));
|
||||
}
|
||||
|
||||
default PageResult<BrokerageBindLogDO> selectPage(Long userId, PageParam page) {
|
||||
return selectPage(page, new LambdaQueryWrapperX<BrokerageBindLogDO>()
|
||||
.eq(BrokerageBindLogDO::getUserId, userId).orderByDesc(BrokerageBindLogDO::getId));
|
||||
}
|
||||
}
|
||||
+14
@@ -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<BrokerageRechargeRewardDO> {
|
||||
// 资格是永久凭据,即使记录被软删除也不恢复首奖资格。
|
||||
@Select("SELECT * FROM trade_brokerage_recharge_reward WHERE source_user_id = #{userId} FOR UPDATE")
|
||||
BrokerageRechargeRewardDO selectBySourceForUpdate(@Param("userId") Long userId);
|
||||
}
|
||||
+13
@@ -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();
|
||||
}
|
||||
+13
@@ -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<BrokerageUserDO> {
|
||||
|
||||
default BrokerageUserDO selectByIdForUpdate(Long id) {
|
||||
return selectOne(new LambdaQueryWrapperX<BrokerageUserDO>()
|
||||
.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<BrokerageUserDO> selectChildrenPage(Long bindUserId, PageParam pageParam) {
|
||||
return selectPage(pageParam, new LambdaQueryWrapperX<BrokerageUserDO>()
|
||||
.eq(BrokerageUserDO::getBindUserId, bindUserId)
|
||||
|
||||
+5
@@ -61,6 +61,11 @@ public interface BrokerageRecordService {
|
||||
*/
|
||||
void addBrokerage(Long userId, BrokerageRecordBizTypeEnum bizType, @Valid List<BrokerageAddReqBO> list);
|
||||
|
||||
/** 返回本次支付快照中的一级上级,供商城订单保存实际归属。 */
|
||||
Long addOrderBrokerage(Long userId, @Valid List<BrokerageAddReqBO> list);
|
||||
|
||||
void cancelOrderBrokerage(String bizId, Long sourceUserId);
|
||||
|
||||
/**
|
||||
* 添加朋友圈分销【多级分佣】
|
||||
* 首次分销才有用
|
||||
|
||||
+84
-69
@@ -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<BrokerageAddReqBO> 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<BrokerageAddReqBO> 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<BrokerageAddReqBO> 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<BrokerageAddReqBO> list, String scene) {
|
||||
if (snapshot.getFirstUserId() == null || config == null || CollUtil.isEmpty(list)) {
|
||||
return;
|
||||
}
|
||||
// 1.1.1查看一级分销员是否已经分销过
|
||||
Long firstSuccessCount = brokerageRecordMapper.selectCount(new LambdaQueryWrapperX<BrokerageRecordDO>()
|
||||
.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<Long, BrokerageUserDO> 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<BrokerageRecordDO>()
|
||||
.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<BrokerageRecordDO> records = brokerageRecordMapper.selectList(new LambdaQueryWrapperX<BrokerageRecordDO>()
|
||||
.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<BrokerageRecordDO> 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<BrokerageAddReqBO> 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) { // 更新用户冻结佣金
|
||||
|
||||
+16
-4
@@ -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<BrokerageBindLogDO> getBindLogPage(Long id, PageParam page);
|
||||
|
||||
BrokerageRelationSnapshot getRelationSnapshot(Long id);
|
||||
|
||||
BrokerageUserDO lockBrokerageUser(Long id);
|
||||
|
||||
/**
|
||||
* 修改推广资格
|
||||
|
||||
+210
-83
@@ -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<BrokerageBindLogDO> 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<BrokerageUserDO>()
|
||||
.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<Long> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -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;
|
||||
}
|
||||
+11
-13
@@ -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<TradeOrderItemDO> orderItems) {
|
||||
@@ -54,10 +58,10 @@ public class TradeBrokerageOrderHandler implements TradeOrderHandler {
|
||||
|
||||
@Override
|
||||
public void afterPayOrder(TradeOrderDO order, List<TradeOrderItemDO> orderItems) {
|
||||
if (order.getBrokerageUserId() == null) {
|
||||
return;
|
||||
}
|
||||
addBrokerage(order.getUserId(), orderItems);
|
||||
Long recipientId = addBrokerage(order.getUserId(), orderItems);
|
||||
tradeOrderMapper.update(null, new LambdaUpdateWrapper<TradeOrderDO>()
|
||||
.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<TradeOrderItemDO> orderItems) {
|
||||
protected Long addBrokerage(Long userId, List<TradeOrderItemDO> 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<BrokerageAddReqBO> addList = convertList(orderItems,
|
||||
item -> TradeOrderConvert.INSTANCE.convert(user, item, spu, sku));
|
||||
brokerageRecordService.addBrokerage(userId, BrokerageRecordBizTypeEnum.ORDER, addList);
|
||||
return brokerageRecordService.addOrderBrokerage(userId, addList);
|
||||
}
|
||||
|
||||
}
|
||||
+42
@@ -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<String> 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());
|
||||
}
|
||||
}
|
||||
+472
@@ -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> 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<BrokerageAddReqBO> 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<Boolean> 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<Boolean> 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<Boolean> 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<Long> 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<Void>) 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> T inTenant(Callable<T> task) throws Exception {
|
||||
TenantContextHolder.setTenantId(1L);
|
||||
try { return task.call(); } finally { TenantContextHolder.clear(); }
|
||||
}
|
||||
private List<Boolean> parallel(Callable<Boolean> first, Callable<Boolean> second) throws Exception {
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try {
|
||||
List<Future<Boolean>> jobs = new ArrayList<>();
|
||||
for (Callable<Boolean> 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); }
|
||||
}
|
||||
}
|
||||
+94
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+1
@@ -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 '',
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+5
@@ -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<MemberUserRespDTO> getUserList(Collection<Long> ids);
|
||||
|
||||
/** 按 App ID、昵称或手机号分页搜索会员,供分销上级选择复用。 */
|
||||
PageResult<MemberUserRespDTO> searchUserPage(String searchField, String keyword, PageParam page);
|
||||
|
||||
/**
|
||||
* 获得会员用户 Map
|
||||
*
|
||||
|
||||
+18
@@ -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<MemberUserRespDTO> 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<MemberUserRespDTO> getUserListByNickname(String nickname) {
|
||||
return MemberUserConvert.INSTANCE.convertList2(userService.getUserListByNickname(nickname));
|
||||
|
||||
+4
-1
@@ -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;
|
||||
|
||||
}
|
||||
+44
@@ -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<PageResult<ChatPrivateMessageRespVO>> page(@Valid @RequestBody ChatPrivateMessageReqVO dto) {
|
||||
ChatTableRespVO<ChatPrivateMessageRespVO> 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<ChatPrivateMessageRespVO> messages) {
|
||||
if (CollUtil.isEmpty(messages)) {
|
||||
return;
|
||||
}
|
||||
Set<Long> userIds = convertSet(messages, ChatPrivateMessageRespVO::getSendId);
|
||||
userIds.addAll(convertSet(messages, ChatPrivateMessageRespVO::getRecvId));
|
||||
userIds.remove(null);
|
||||
Map<Long, MemberUserDO> 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<Long, MemberUserDO> 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<List<Map>> dailyCount(@RequestParam("days") Integer days) {
|
||||
|
||||
+5
-1
@@ -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;
|
||||
|
||||
}
|
||||
}
|
||||
+19
@@ -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<PageResult<Response>> page(@Valid Page request) { return success(service.page(request)); }
|
||||
@PutMapping("/update-status") @PreAuthorize("@ss.hasPermission('app:setting:booking')")
|
||||
public CommonResult<Boolean> update(@RequestBody @Valid Update request) { service.update(request); return success(true); }
|
||||
}
|
||||
|
||||
+19
@@ -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<Setting> get() { return success(service.getSetting(null)); }
|
||||
@PutMapping("/save") @PreAuthorize("@ss.hasPermission('app:setting:update')")
|
||||
public CommonResult<Boolean> save(@Valid @RequestBody Setting request) { service.saveSetting(request); return success(true); }
|
||||
}
|
||||
|
||||
+22
@@ -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<PageResult<Booking>> page(@Valid BookingPage request) { return success(service.page(request, null)); }
|
||||
@GetMapping("/get") @PreAuthorize("@ss.hasPermission('app:setting:query')")
|
||||
public CommonResult<Booking> get(@RequestParam @Positive Long id) { return success(service.booking(id, null)); }
|
||||
@PutMapping("/update-status") @PreAuthorize("@ss.hasPermission('app:setting:booking')")
|
||||
public CommonResult<Boolean> update(@RequestBody @Valid StatusUpdate request) { service.updateStatus(request); return success(true); }
|
||||
}
|
||||
|
||||
+27
@@ -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<Room>> list() { return success(service.rooms(false)); }
|
||||
@GetMapping("/get") @PreAuthorize("@ss.hasPermission('app:setting:query')")
|
||||
public CommonResult<Room> get(@RequestParam @Positive Long id) { return success(service.room(id, false)); }
|
||||
@PostMapping("/create") @PreAuthorize("@ss.hasPermission('app:setting:update')")
|
||||
public CommonResult<Long> create(@RequestBody @Valid Room request) { return success(service.saveRoom(request, true)); }
|
||||
@PutMapping("/update") @PreAuthorize("@ss.hasPermission('app:setting:update')")
|
||||
public CommonResult<Boolean> update(@RequestBody @Valid Room request) { service.saveRoom(request, false); return success(true); }
|
||||
@DeleteMapping("/delete") @PreAuthorize("@ss.hasPermission('app:setting:update')")
|
||||
public CommonResult<Boolean> delete(@RequestParam @Positive Long id) { service.disableRoom(id); return success(true); }
|
||||
}
|
||||
|
||||
+20
@@ -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<Long> create(@RequestBody @Valid Create request) {
|
||||
return success(service.create(getLoginUserId(), request));
|
||||
}
|
||||
@GetMapping("/latest") public CommonResult<Response> latest() { return success(service.latest(getLoginUserId())); }
|
||||
}
|
||||
|
||||
+25
@@ -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<Long> create(@Valid @RequestBody BookingCreate request) { return success(service.createBooking(getLoginUserId(), request)); }
|
||||
@GetMapping("/page")
|
||||
public CommonResult<PageResult<Booking>> page(@Valid BookingPage request) { return success(service.page(request, getLoginUserId())); }
|
||||
@GetMapping("/get")
|
||||
public CommonResult<Booking> get(@RequestParam @Positive Long id) { return success(service.booking(id, getLoginUserId())); }
|
||||
@PutMapping("/cancel")
|
||||
public CommonResult<Boolean> cancel(@Valid @RequestBody IdRequest request) { service.cancel(request.getId(), getLoginUserId()); return success(true); }
|
||||
}
|
||||
|
||||
+34
@@ -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> 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<Room>> list() { return success(service.rooms(true)); }
|
||||
@PermitAll @GetMapping("/tea-room/get")
|
||||
public CommonResult<Room> room(@RequestParam @Positive Long id) { return success(service.room(id, true)); }
|
||||
@PermitAll @GetMapping("/tea-room/slots")
|
||||
public CommonResult<List<AvailableSlot>> 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));
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+129
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -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;
|
||||
}
|
||||
|
||||
+17
@@ -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;
|
||||
}
|
||||
|
||||
+37
@@ -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;
|
||||
}
|
||||
|
||||
+24
@@ -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<String> facilities;
|
||||
private String description;
|
||||
private Integer price;
|
||||
private Integer sort;
|
||||
private Boolean enabled;
|
||||
}
|
||||
|
||||
+9
@@ -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<MemberAccountClosureDO> {
|
||||
@Select("SELECT * FROM member_account_closure_request WHERE id = #{id} AND deleted = 0 FOR UPDATE")
|
||||
MemberAccountClosureDO selectForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
+10
@@ -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<MemberAppSettingDO> {
|
||||
@Select("SELECT * FROM member_app_setting WHERE config_key = 'main' AND deleted = 0 FOR UPDATE")
|
||||
MemberAppSettingDO selectCurrentForUpdate();
|
||||
}
|
||||
|
||||
+11
@@ -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<MemberTeaBookingDO> {
|
||||
/** 加锁读取避免事务快照漏掉刚刚确认的预约。 */
|
||||
@Select("SELECT * FROM member_tea_booking WHERE id = #{id} AND deleted = 0 FOR UPDATE")
|
||||
MemberTeaBookingDO selectForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
+11
@@ -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<MemberTeaRoomDO> {
|
||||
/** 包间作为所有预约写操作的共同锁,查询仍经过租户组件。 */
|
||||
@Select("SELECT * FROM member_tea_room WHERE id = #{id} AND deleted = 0 FOR UPDATE")
|
||||
MemberTeaRoomDO selectForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
+5
-2
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+4
@@ -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();
|
||||
// 判断是否是会员
|
||||
|
||||
+36
-1
@@ -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<MemberPushRecordDO> eligible = filterEligible(task, fresh);
|
||||
if (!eligible.isEmpty()) {
|
||||
sendNewBatch(task, eligible);
|
||||
// 每个平台有独立 CID,单端切换业务不影响另一端重试。
|
||||
for (List<MemberPushRecordDO> platformBatch : eligible.stream()
|
||||
.collect(Collectors.groupingBy(MemberPushRecordDO::getPlatform)).values()) {
|
||||
sendNewBatch(task, platformBatch);
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshSummary(task);
|
||||
@@ -323,6 +330,13 @@ public class MemberPushTaskService {
|
||||
}
|
||||
|
||||
private List<MemberPushRecordDO> filterEligible(MemberPushTaskDO task, List<MemberPushRecordDO> 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<Long> deviceIds = records.stream().map(MemberPushRecordDO::getDeviceId).collect(Collectors.toList());
|
||||
Map<Long, MemberPushDeviceDO> devices = deviceMapper.selectList(new LambdaQueryWrapper<MemberPushDeviceDO>()
|
||||
.in(MemberPushDeviceDO::getId, deviceIds)
|
||||
@@ -488,6 +507,12 @@ public class MemberPushTaskService {
|
||||
}
|
||||
|
||||
private void submitBatch(MemberPushTaskDO task, List<MemberPushRecordDO> 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<MemberPushRecordDO> 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<MemberPushRecordDO> batch(Long taskId, String cid) {
|
||||
return new LambdaUpdateWrapper<MemberPushRecordDO>()
|
||||
.eq(MemberPushRecordDO::getTaskId, taskId).eq(MemberPushRecordDO::getCid, cid)
|
||||
|
||||
+72
@@ -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<MemberAccountClosureDO> active = mapper.selectList(new LambdaQueryWrapper<MemberAccountClosureDO>()
|
||||
.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<MemberAccountClosureDO> rows = mapper.selectList(new LambdaQueryWrapper<MemberAccountClosureDO>()
|
||||
.eq(MemberAccountClosureDO::getMemberId, memberId).orderByDesc(MemberAccountClosureDO::getId).last("LIMIT 1"));
|
||||
return rows.isEmpty() ? null : BeanUtils.toBean(rows.get(0), Response.class);
|
||||
}
|
||||
|
||||
public PageResult<Response> page(Page request) {
|
||||
LambdaQueryWrapper<MemberAccountClosureDO> query = new LambdaQueryWrapper<MemberAccountClosureDO>()
|
||||
.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<MemberAccountClosureDO> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+364
@@ -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<Slot> 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<MemberTeaRoomDO>()
|
||||
.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<Room> rooms(boolean publicOnly) {
|
||||
return roomMapper.selectList(new LambdaQueryWrapper<MemberTeaRoomDO>()
|
||||
.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<AvailableSlot> slots(Long roomId, String date) {
|
||||
room(roomId, true);
|
||||
LocalDate day = bookingDate(date);
|
||||
Setting setting = getSetting(null);
|
||||
List<MemberTeaBookingDO> confirmed = bookingMapper.selectList(new LambdaQueryWrapper<MemberTeaBookingDO>()
|
||||
.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<MemberTeaBookingDO> repeats = bookingMapper.selectList(new LambdaQueryWrapper<MemberTeaBookingDO>()
|
||||
.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<MemberTeaBookingDO> duplicates = bookingMapper.selectList(new LambdaQueryWrapper<MemberTeaBookingDO>()
|
||||
.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<MemberTeaBookingDO> conflict = bookingMapper.selectList(new LambdaQueryWrapper<MemberTeaBookingDO>()
|
||||
.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<Booking> page(BookingPage request, Long memberId) {
|
||||
LambdaQueryWrapper<MemberTeaBookingDO> query = new LambdaQueryWrapper<MemberTeaBookingDO>()
|
||||
.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<MemberTeaBookingDO> 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); }
|
||||
}
|
||||
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
@@ -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(); }
|
||||
}
|
||||
|
||||
+96
-3
@@ -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<ChatPrivateMessageRespVO> messages = controller.page(new ChatPrivateMessageReqVO()).getData();
|
||||
assertTrue(messages.getList().isEmpty());
|
||||
assertEquals(23L, messages.getTotal());
|
||||
PageResult<ChatPrivateConversationRespVO> 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<ChatPrivateMessageRespVO> 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
|
||||
|
||||
+97
@@ -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<TxMapGeocoderReqVO> 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<LocationDO> 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<TxMapGeocoderReqVO> geocode = ArgumentCaptor.forClass(TxMapGeocoderReqVO.class);
|
||||
verify(txMapApi).geocoder(geocode.capture());
|
||||
assertThat(geocode.getValue().getCoord_type()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void gcj02RequestsUseTheMapDefault() {
|
||||
service.createLocation(request("gcj02"));
|
||||
|
||||
ArgumentCaptor<TxMapGeocoderReqVO> 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;
|
||||
}
|
||||
}
|
||||
+9
@@ -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);
|
||||
|
||||
+91
@@ -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; }
|
||||
}
|
||||
|
||||
+5
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+47
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+113
@@ -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<String> 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(); }
|
||||
}
|
||||
}
|
||||
|
||||
+168
@@ -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<Future<Boolean>> 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<Boolean> 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));
|
||||
}
|
||||
}
|
||||
|
||||
+156
@@ -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<MemberTeaRoomDO> 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user