feat:增加伪装方便ios上架

This commit is contained in:
dzc committed 2026-11-12 14:03:09 +08:00
1 parent fc36d1b17a
commit f3acddd021
70 files changed
+3282 -191

No files matched your search

+121
View File
@@ -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;
@@ -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, "佣金提现记录不存在");
@@ -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')")
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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 {
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -47,6 +47,9 @@ public class BrokerageRecordDO extends BaseDO {
*/
private Integer bizType;
/** 资金来源命名空间,区分共用 ORDER 类型的充值和商城订单项。 */
private String bizScene;
/**
* 标题
*/
@@ -42,6 +42,9 @@ public class BrokerageUserDO extends BaseDO {
*/
private LocalDateTime bindUserTime;
/** 仅在关系实际变化时递增,防止陈旧页面覆盖及 A→B→A。 */
private Long bindVersion;
/**
* 是否有分销资格
*/
@@ -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));
}
}
@@ -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);
}
@@ -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();
}
@@ -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)
@@ -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);
/**
* 添加朋友圈分销【多级分佣】
* 首次分销才有用
@@ -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) { // 更新用户冻结佣金
@@ -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);
/**
* 修改推广资格
@@ -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);
}
}
}
@@ -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;
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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); }
}
}
@@ -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);
}
}
@@ -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,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,
@@ -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
*
@@ -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));
@@ -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;
}
@@ -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) {
@@ -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;
}
}
@@ -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); }
}
@@ -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); }
}
@@ -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); }
}
@@ -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); }
}
@@ -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())); }
}
@@ -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); }
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
@@ -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();
}
@@ -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);
}
@@ -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);
}
@@ -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 {
}
}
}
}
@@ -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();
// 判断是否是会员
@@ -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)
@@ -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);
}
}
@@ -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); }
}
@@ -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);
}
}
@@ -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(); }
}
@@ -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
@@ -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;
}
}
@@ -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);
@@ -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; }
}
@@ -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;
}
@@ -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));
}
}
@@ -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(); }
}
}
@@ -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));
}
}
@@ -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;
}
}