feat:商户结账

This commit is contained in:
dzc committed 2026-11-09 17:41:07 +08:00
1 parent 78a3b9d123
commit f0a36aa621
62 files changed
+4154 -50

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
-- 酒店商户结账一期:仅在钱包业务库执行。本脚本可重复执行。
-- 要求MySQL 8.0.16或更新版本,使金额CHECK约束实际生效。
-- 酒店表作为只读数据源;财务快照通过独立结账表保存。
-- 脚本交付与数据库实际执行分别授权。
CREATE TABLE IF NOT EXISTS `mch_checkout_bill` (
`id` BIGINT NOT NULL COMMENT '应用分配的雪花主键',
`bill_no` VARCHAR(64) NOT NULL COMMENT '结账单编号,使用业务前缀和雪花主键',
`source_type` VARCHAR(32) NOT NULL COMMENT '订单业务类型,当前为HOTEL',
`relation_id` BIGINT NOT NULL COMMENT '发起时的结账关系主键',
`relation_version` INT NOT NULL COMMENT '发起时的结账关系版本',
`intermediary_name` VARCHAR(100) NOT NULL COMMENT '发起时的中间方名称快照',
`period_start` DATE NOT NULL COMMENT '离店周期开始日期,包含当天',
`period_end` DATE NOT NULL COMMENT '离店周期结束日期,包含当天',
`rule_version` INT NOT NULL COMMENT '金额计算规则版本,当前为1',
`fee_per_order_fen` BIGINT NOT NULL COMMENT '每笔订单抽成,单位分',
`order_count` INT NOT NULL COMMENT '实际纳入账单的订单笔数',
`merchant_count` INT NOT NULL COMMENT '关系全部商户快照数量,含零订单商户',
`gross_amount_fen` BIGINT NOT NULL COMMENT '原始订单金额合计,单位分',
`fee_amount_fen` BIGINT NOT NULL COMMENT '原始抽成合计,单位分',
`payable_amount_fen` BIGINT NOT NULL COMMENT '原始商户应付合计,单位分',
`state` VARCHAR(32) NOT NULL COMMENT 'WAIT_PAY待付款、PART_PAID部分已付、FINISHED已结清、VOID已作废',
`version` INT NOT NULL DEFAULT 0 COMMENT '每次账单状态汇总更新时递增的并发版本',
`create_request_id` VARCHAR(64) NOT NULL COMMENT '创建请求幂等号,租户内唯一',
`request_hash` CHAR(64) NOT NULL COMMENT '规范化创建内容的SHA256摘要',
`preview_token` VARCHAR(64) NOT NULL COMMENT '创建时的预览令牌,供缓存失效后幂等重试匹配',
`source_verified_at` DATETIME(6) NOT NULL COMMENT '创建前最近一次酒店源数据复核时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '本次结账备注,最多500字',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人账号',
`create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,服务端时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '最后修改人账号',
`update_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后修改时间,服务端时间',
`tenant_id` BIGINT NOT NULL COMMENT '所属租户,由平台上下文自动填写',
PRIMARY KEY (`id`),
UNIQUE KEY uk_checkout_bill_no (tenant_id, bill_no),
UNIQUE KEY uk_checkout_bill_request (tenant_id, create_request_id),
KEY idx_checkout_bill_page (tenant_id, create_time, id),
KEY idx_checkout_bill_state (tenant_id, state, create_time, id),
CONSTRAINT ck_checkout_bill_amount CHECK (gross_amount_fen > 0 AND fee_per_order_fen >= 0 AND fee_amount_fen >= 0 AND payable_amount_fen >= 0 AND order_count BETWEEN 1 AND 10000 AND merchant_count BETWEEN 1 AND 1000 AND version >= 0 AND gross_amount_fen = fee_amount_fen + payable_amount_fen)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='商户结账账单,原始财务汇总及归属快照固定保存';
CREATE TABLE IF NOT EXISTS `mch_checkout_bill_merchant` (
`id` BIGINT NOT NULL COMMENT '应用分配的雪花主键',
`bill_id` BIGINT NOT NULL COMMENT '所属结账账单主键',
`merchant_id` BIGINT NOT NULL COMMENT '绑定商户主键快照',
`merchant_name` VARCHAR(255) NOT NULL COMMENT '绑定商户名称快照',
`dept_id` BIGINT NOT NULL COMMENT '发起时的商户直属部门,用于历史数据权限',
`dept_name` VARCHAR(255) DEFAULT NULL COMMENT '发起时的部门名称快照',
`merchant_create_by` VARCHAR(64) DEFAULT NULL COMMENT '商户创建人快照,用于本人数据权限',
`order_count` INT NOT NULL COMMENT '本次选择的订单笔数,零表示无结账订单',
`gross_amount_fen` BIGINT NOT NULL COMMENT '该商户原订单合计,单位分',
`fee_amount_fen` BIGINT NOT NULL COMMENT '该商户抽成合计,单位分',
`payable_amount_fen` BIGINT NOT NULL COMMENT '该商户应付,单位分',
`state` VARCHAR(32) NOT NULL COMMENT 'NO_ORDERS无订单、WAIT_PAY待付款、PAID已付款或结清、VOID已作废',
`version` INT NOT NULL DEFAULT 0 COMMENT '付款和作废操作使用的并发版本',
`payment_time` DATETIME(6) DEFAULT NULL COMMENT '实际线下付款时间,零元时为结清时间',
`paid_amount_fen` BIGINT DEFAULT NULL COMMENT '登记的全额实付,单位分,未付款为空',
`payment_no` VARCHAR(100) DEFAULT NULL COMMENT '线下付款流水号,与凭证至少提供一种',
`payment_pics` JSON DEFAULT NULL COMMENT '付款凭证地址JSON数组,最多9张',
`payment_remark` VARCHAR(500) DEFAULT NULL COMMENT '付款备注,最多500字',
`payment_request_id` VARCHAR(64) DEFAULT NULL COMMENT '付款请求幂等号',
`payment_request_hash` CHAR(64) DEFAULT NULL COMMENT '付款请求规范化内容摘要',
`void_reason` VARCHAR(500) DEFAULT NULL COMMENT '作废原因,最多500字',
`void_request_id` VARCHAR(64) DEFAULT NULL COMMENT '作废请求幂等号',
`void_request_hash` CHAR(64) DEFAULT NULL COMMENT '作废请求规范化内容摘要',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人账号',
`create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,服务端时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '最后修改人账号',
`update_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后修改时间,服务端时间',
`tenant_id` BIGINT NOT NULL COMMENT '所属租户,由平台上下文自动填写',
PRIMARY KEY (`id`),
UNIQUE KEY uk_checkout_bill_merchant (tenant_id, bill_id, merchant_id),
UNIQUE KEY uk_checkout_merchant_pay_request (tenant_id, payment_request_id),
UNIQUE KEY uk_checkout_merchant_void_request (tenant_id, void_request_id),
KEY idx_checkout_merchant_scope (tenant_id, bill_id, dept_id),
KEY idx_checkout_merchant_state (tenant_id, bill_id, state),
CONSTRAINT ck_checkout_billmerchant_amount CHECK (gross_amount_fen >= 0 AND fee_amount_fen >= 0 AND payable_amount_fen >= 0 AND order_count >= 0 AND version >= 0 AND (paid_amount_fen IS NULL OR paid_amount_fen >= 0) AND gross_amount_fen = fee_amount_fen + payable_amount_fen),
CONSTRAINT ck_checkout_merchant_paid CHECK (state != 'PAID' OR (paid_amount_fen IS NOT NULL AND payment_time IS NOT NULL AND paid_amount_fen = payable_amount_fen))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='结账商户明细及完整关系权限快照';
CREATE TABLE IF NOT EXISTS `mch_checkout_bill_order` (
`id` BIGINT NOT NULL COMMENT '应用分配的雪花主键',
`bill_id` BIGINT NOT NULL COMMENT '所属账单主键',
`bill_merchant_id` BIGINT NOT NULL COMMENT '所属商户结账明细主键',
`merchant_id` BIGINT NOT NULL COMMENT '归属商户主键快照',
`source_type` VARCHAR(32) NOT NULL COMMENT '源业务类型,当前为HOTEL',
`source_order_id` BIGINT NOT NULL COMMENT '酒店本地订单主键,唯一占用身份的一部分',
`hotel_id` BIGINT NOT NULL COMMENT '唯一解析的酒店主键快照',
`hotel_name` VARCHAR(255) DEFAULT NULL COMMENT '酒店名称快照',
`dept_id` BIGINT NOT NULL COMMENT '酒店直属归属部门快照',
`channel_no` VARCHAR(255) DEFAULT NULL COMMENT '渠道订单号快照',
`platform_no` VARCHAR(255) DEFAULT NULL COMMENT '上游订单号快照',
`check_in_date` DATE DEFAULT NULL COMMENT '源订单入住日期快照',
`check_out_date` DATE NOT NULL COMMENT '源订单离店日期快照',
`rooms` INT DEFAULT NULL COMMENT '房间数量快照,仅供核对',
`gross_amount_fen` BIGINT NOT NULL COMMENT '该笔订单售价,单位分',
`fee_amount_fen` BIGINT NOT NULL COMMENT '该笔抽成,单位分',
`payable_amount_fen` BIGINT NOT NULL COMMENT '该笔商户应付,单位分',
`order_status` VARCHAR(64) DEFAULT NULL COMMENT '酒店原始订单状态快照',
`book_status` VARCHAR(64) DEFAULT NULL COMMENT '酒店原始预订状态快照',
`pay_status` VARCHAR(64) DEFAULT NULL COMMENT '酒店原始支付状态快照',
`source_updated_at` DATETIME(6) DEFAULT NULL COMMENT '源订单最后更新时间快照',
`source_fingerprint` CHAR(64) NOT NULL COMMENT '源金额归属状态等关键事实的SHA256摘要',
`source_snapshot` JSON NOT NULL COMMENT '不含住客和联系人信息的酒店事实JSON快照',
`confirmed_user_id` BIGINT NOT NULL COMMENT '履约完成及收款核对人的管理用户主键',
`confirmed_by` VARCHAR(64) NOT NULL COMMENT '履约完成及收款核对人的账号',
`confirmed_at` DATETIME(6) NOT NULL COMMENT '服务端记录的履约与收款人工确认时间',
`last_check_status` VARCHAR(32) NOT NULL COMMENT 'NORMAL一致、DIFF差异、QUERY_FAILED查询失败、HANDLED人工处理',
`last_check_message` VARCHAR(500) DEFAULT NULL COMMENT '最近一次核对的可读结果',
`last_observed_fingerprint` CHAR(64) DEFAULT NULL COMMENT '最近观察到的源事实摘要',
`handled_fingerprint` CHAR(64) DEFAULT NULL COMMENT '已人工处理的差异摘要,新差异重新提醒',
`last_checked_at` DATETIME(6) DEFAULT NULL COMMENT '最近一次复核尝试时间',
`last_verified_at` DATETIME(6) DEFAULT NULL COMMENT '最近一次成功读取源数据时间,失败时保留',
`next_check_at` DATETIME(6) DEFAULT NULL COMMENT '下次后台复核时间,作废后为空',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人账号',
`create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,服务端时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '最后修改人账号',
`update_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后修改时间,服务端时间',
`tenant_id` BIGINT NOT NULL COMMENT '所属租户,由平台上下文自动填写',
PRIMARY KEY (`id`),
UNIQUE KEY uk_checkout_bill_order (tenant_id, bill_id, source_type, source_order_id),
KEY idx_checkout_order_page (tenant_id, bill_id, bill_merchant_id, id),
KEY idx_checkout_order_recheck (tenant_id, next_check_at, id),
CONSTRAINT ck_checkout_billorder_amount CHECK (gross_amount_fen > 0 AND fee_amount_fen >= 0 AND payable_amount_fen >= 0 AND gross_amount_fen = fee_amount_fen + payable_amount_fen)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='结账订单不可变金额快照及后续复核结果';
CREATE TABLE IF NOT EXISTS `mch_checkout_order_claim` (
`id` BIGINT NOT NULL COMMENT '应用分配的雪花主键',
`source_type` VARCHAR(32) NOT NULL COMMENT '源业务类型,当前为HOTEL',
`source_order_id` BIGINT NOT NULL COMMENT '源订单主键,租户和类型下唯一占用',
`bill_id` BIGINT NOT NULL COMMENT '占用账单主键',
`bill_merchant_id` BIGINT NOT NULL COMMENT '占用的商户结账明细主键',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人账号',
`create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,服务端时间',
`tenant_id` BIGINT NOT NULL COMMENT '所属租户,由平台上下文自动填写',
PRIMARY KEY (`id`),
UNIQUE KEY uk_checkout_order_claim (tenant_id, source_type, source_order_id),
KEY idx_checkout_claim_merchant (tenant_id, bill_id, bill_merchant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='当前有效结账订单占用,作废待付款明细时释放';
CREATE TABLE IF NOT EXISTS `mch_checkout_bill_event` (
`id` BIGINT NOT NULL COMMENT '应用分配的雪花主键',
`bill_id` BIGINT NOT NULL COMMENT '事件所属账单主键',
`bill_merchant_id` BIGINT DEFAULT NULL COMMENT '商户明细主键,整单事件为空',
`source_order_id` BIGINT DEFAULT NULL COMMENT '源订单主键,非订单事件为空',
`action` VARCHAR(32) NOT NULL COMMENT 'CREATE、PAY、VOID、RECHECK、DIFF、QUERY_FAILED、HANDLE业务动作',
`request_id` VARCHAR(64) NOT NULL COMMENT '该动作的请求幂等号或系统事件号',
`request_hash` CHAR(64) NOT NULL COMMENT '该动作请求摘要,供审计核对',
`payload` JSON NOT NULL COMMENT '业务依据和结果JSON,不包含凭据或酒店住客资料',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人账号',
`create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,服务端时间',
`tenant_id` BIGINT NOT NULL COMMENT '所属租户,由平台上下文自动填写',
PRIMARY KEY (`id`),
UNIQUE KEY uk_checkout_event_request (tenant_id, action, request_id),
KEY idx_checkout_event_page (tenant_id, bill_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='结账业务追加式审计事件';
@@ -0,0 +1,59 @@
-- 在权限库中执行。先在同一会话明确设置目标租户和父菜单。
-- SET @checkout_bill_tenant_id = 目标租户ID;
-- SET @checkout_bill_parent_id = 商户结账管理父菜单ID;
-- 脚本安装记录页和按钮权限,角色按实际职责另行授权。
DELIMITER $$
DROP PROCEDURE IF EXISTS migrate_20261109_mch_checkout_bill_menu$$
CREATE PROCEDURE migrate_20261109_mch_checkout_bill_menu()
BEGIN
DECLARE v_parent BIGINT;
DECLARE v_setting BIGINT;
DECLARE v_record BIGINT;
DECLARE v_count INT;
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
IF @checkout_bill_tenant_id IS NULL OR @checkout_bill_tenant_id <= 0
OR @checkout_bill_parent_id IS NULL OR @checkout_bill_parent_id <= 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='请先设置结账菜单目标租户和父菜单ID';
END IF;
START TRANSACTION;
SELECT menu_id INTO v_parent FROM sys_menu WHERE menu_id=@checkout_bill_parent_id
AND tenant_id=@checkout_bill_tenant_id AND del_flag='0' AND menu_type='0' FOR UPDATE;
IF v_parent IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='指定父菜单不存在或类型不符'; END IF;
SELECT COUNT(*),MAX(menu_id) INTO v_count,v_setting FROM sys_menu
WHERE tenant_id=@checkout_bill_tenant_id AND del_flag='0' AND path='/mch/checkout/setting/index';
IF v_count != 1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='请先安装唯一的商户结账配置页面'; END IF;
SELECT COUNT(*),MAX(menu_id) INTO v_count,v_record FROM sys_menu
WHERE tenant_id=@checkout_bill_tenant_id AND del_flag='0' AND path='/mch/checkout/record/index';
IF v_count > 1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='存在重复结账记录菜单,请先核对'; END IF;
IF v_count = 1 AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id=v_record AND parent_id=v_parent
AND menu_type='0' AND (component IS NULL OR component='' OR component='mch/checkout/record/index')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='现有结账记录菜单位置或组件不符,请先核对';
END IF;
IF v_count = 0 THEN
SET v_record=UUID_SHORT();
INSERT INTO sys_menu(menu_id,name,path,component,parent_id,icon,visible,sort_order,keep_alive,menu_type,create_by,create_time,update_by,update_time,del_flag,tenant_id)
VALUES(v_record,'商户结账记录','/mch/checkout/record/index','mch/checkout/record/index',v_parent,'ele-Tickets','1',81,'1','0','admin',NOW(),'admin',NOW(),'0',@checkout_bill_tenant_id);
ELSE
UPDATE sys_menu SET component='mch/checkout/record/index' WHERE menu_id=v_record AND tenant_id=@checkout_bill_tenant_id;
END IF;
IF EXISTS (SELECT 1 FROM sys_menu WHERE tenant_id=@checkout_bill_tenant_id AND del_flag='0'
AND permission IN ('wallet_mchCheckoutBill_view','wallet_mchCheckoutBill_add','wallet_mchCheckoutBill_pay','wallet_mchCheckoutBill_void','wallet_mchCheckoutBill_recheck','wallet_mchCheckoutBill_handle')
AND (menu_type != '1' OR parent_id != CASE WHEN permission='wallet_mchCheckoutBill_add' THEN v_setting ELSE v_record END)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='结账权限已被其他菜单占用,请先核对';
END IF;
INSERT INTO sys_menu(menu_id,name,permission,parent_id,visible,sort_order,keep_alive,menu_type,create_by,create_time,update_by,update_time,del_flag,tenant_id)
SELECT UUID_SHORT(),p.name,p.permission,CASE WHEN p.permission='wallet_mchCheckoutBill_add' THEN v_setting ELSE v_record END,
'1',p.sort_order,'0','1','admin',NOW(),'admin',NOW(),'0',@checkout_bill_tenant_id
FROM (
SELECT '查看结账账单' name,'wallet_mchCheckoutBill_view' permission,1 sort_order
UNION ALL SELECT '发起酒店结账','wallet_mchCheckoutBill_add',2
UNION ALL SELECT '登记商户付款','wallet_mchCheckoutBill_pay',3
UNION ALL SELECT '作废商户明细','wallet_mchCheckoutBill_void',4
UNION ALL SELECT '复核订单事实','wallet_mchCheckoutBill_recheck',5
UNION ALL SELECT '处理付款差异','wallet_mchCheckoutBill_handle',6
) p WHERE NOT EXISTS (SELECT 1 FROM sys_menu m WHERE m.tenant_id=@checkout_bill_tenant_id AND m.del_flag='0' AND m.permission=p.permission);
COMMIT;
END$$
CALL migrate_20261109_mch_checkout_bill_menu()$$
DROP PROCEDURE migrate_20261109_mch_checkout_bill_menu$$
DELIMITER ;
+18
View File
@@ -0,0 +1,18 @@
-- 在钱包业务库执行,微服务通常为 pjcloudx_wallet,单体以实际业务库为准。
-- 商户和场景各自独立配置,父店与分店使用各自 mch_id,首次保存时创建记录。
-- 重复执行不会重复建表,应用通过唯一键保证同租户、商户和场景只有一条记录。
CREATE TABLE IF NOT EXISTS `mch_scene`
(
`id` BIGINT NOT NULL COMMENT '配置主键,应用分配雪花ID',
`mch_id` BIGINT NOT NULL COMMENT '商户ID,父店和分店独立配置',
`scene_code` VARCHAR(32) NOT NULL COMMENT '场景编码,HOTEL_COMMISSION酒店抽成',
`enabled` CHAR(1) NOT NULL DEFAULT '0' COMMENT '是否开启,0关闭,1开启',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人账号,公共填充器维护',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间,服务端时区',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '最后修改人账号,公共填充器维护',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最后修改时间,服务端时区',
`del_flag` CHAR(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记,0有效,1已删除',
`tenant_id` BIGINT NOT NULL COMMENT '租户ID,公共租户组件维护',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_mch_scene` (`tenant_id`, `mch_id`, `scene_code`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '商户场景配置';
@@ -0,0 +1,12 @@
-- 在钱包业务库执行;尚未建表时,先执行 20261109_add_mch_scene.sql。
-- 酒店抽成按每单完成的酒店订单配置固定金额,单位元,保留两位小数。
-- 旧配置默认获得 0.00 元;重复执行会跳过已有字段。
SET @mch_scene_amount_ddl = IF(
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'mch_scene'
AND column_name = 'commission_amount') = 0,
'ALTER TABLE `mch_scene` ADD COLUMN `commission_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT ''每单完成酒店订单的固定抽成金额,单位元'' AFTER `enabled`',
'SELECT ''commission_amount 已存在'' AS message');
PREPARE mch_scene_amount_stmt FROM @mch_scene_amount_ddl;
EXECUTE mch_scene_amount_stmt;
DEALLOCATE PREPARE mch_scene_amount_stmt;
@@ -0,0 +1,25 @@
package com.bsd.pjcloud.supermarket.api.feign;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.feign.annotation.NoToken;
import com.bsd.pjcloud.supermarket.dto.SmHotelOrderCheckoutQueryDto;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import java.util.List;
/** 钱包结账独立只读酒店契约,调用方负责完整授权和业务状态检查。 */
@FeignClient(contextId = "remoteSmHotelOrderCheckoutQueryService", value = ServiceNameConstants.SUPERMARKET_SERVICE)
public interface RemoteSmHotelOrderCheckoutQueryService {
/** 按部门、周期和固定上界游标读取最多500笔订单。 */
@NoToken
@PostMapping("/inner/sm-hotel-order-checkout-query/scan")
R<SmHotelOrderCheckoutQueryVo.ScanResult> scan(@RequestBody SmHotelOrderCheckoutQueryDto.Scan request);
/** 读取最多500个明确主键的当前事实,响应必须包含请求中的每个主键。 */
@NoToken
@PostMapping("/inner/sm-hotel-order-checkout-query/by-ids")
R<List<SmHotelOrderCheckoutQueryVo>> byIds(@RequestBody SmHotelOrderCheckoutQueryDto.ByIds request);
}
@@ -0,0 +1,38 @@
package com.bsd.pjcloud.supermarket.dto;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
/** 酒店结账专用只读请求,部门范围由钱包服务计算。 */
public final class SmHotelOrderCheckoutQueryDto {
/** 请求容器仅承载跨服务契约。 */
private SmHotelOrderCheckoutQueryDto() { }
/** 按离店周期和稳定主键游标读取,单批最多500笔。 */
@Data
public static class Scan {
/** 精确酒店归属部门,空集合禁止退化为全租户查询。 */
@NotEmpty @Size(max = 1000)
private List<@NotNull @Positive Long> deptIds;
/** 离店日期起点,包含当天。 */
@NotNull private LocalDate periodStart;
/** 离店日期终点,包含当天。 */
@NotNull private LocalDate periodEnd;
/** 已读最大订单主键,首批为0。 */
@NotNull @Min(0) private Long afterId = 0L;
/** 首批返回的扫描上界,后续批次原样提交。 */
@Positive private Long upperId;
/** 单批请求数量,最大500。 */
@NotNull @Min(1) @Max(500) private Integer size = 500;
}
/** 按明确主键读取现状,缺失项也逐项返回。 */
@Data
public static class ByIds {
/** 酒店订单主键集合,单批最多500笔。 */
@NotEmpty @Size(max = 500)
private List<@NotNull @Positive Long> orderIds;
}
}
@@ -0,0 +1,60 @@
package com.bsd.pjcloud.supermarket.vo;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 结账读取的最小酒店事实,不携带联系人、住客及支付配置。 */
@Data
public class SmHotelOrderCheckoutQueryVo {
/** 本地酒店订单主键,始终用它识别订单。 */
private Long orderId;
/** 当前订单是否存在且有效,缺失保留请求主键。 */
private boolean found;
/** 渠道订单号,仅供财务核对。 */
private String channelNo;
/** 上游订单号,仅供财务核对。 */
private String platformNo;
/** 唯一解析的酒店主键。 */
private Long hotelId;
/** 酒店名称,无法唯一解析时为空。 */
private String hotelName;
/** 酒店直属归属部门。 */
private Long deptId;
/** 酒店数据来源,沿用sheet_name原值。 */
private String hotelDataSource;
/** 订单渠道编码,空值仍保留原语义。 */
private String partnerCode;
/** 开放应用主键,空值表示未指定应用。 */
private Long downstreamAppId;
/** 入住日期。 */
private LocalDate checkInDate;
/** 离店日期。 */
private LocalDate checkOutDate;
/** 房间数量,仅供展示,不作为抽成次数。 */
private Integer rooms;
/** 原订单售价,单位元,原始精度通过十进制字符串传输。 */
private String sellAmountYuan;
/** 酒店原始订单状态。 */
private String orderStatus;
/** 酒店原始预订状态。 */
private String bookStatus;
/** 酒店原始支付状态,渠道非标准编码原样保留。 */
private String payStatus;
/** 源订单最后更新时间。 */
private LocalDateTime updateTime;
/** 归属解析结果,OK或MISSING_HOTEL或AMBIGUOUS_HOTEL或MISSING_ORDER。 */
private String resolutionStatus;
/** 归属异常原因,供结账侧明确展示。 */
private String resolutionMessage;
/** 一批稳定游标结果,跨批变化由钱包提交时逐项复核。 */
@Data
public static class ScanResult {
/** 首批固定的订单主键上界,空范围为0。 */
private Long upperId;
/** 当前批次全部记录,最大500。 */
private List<SmHotelOrderCheckoutQueryVo> records;
}
}
@@ -0,0 +1,35 @@
package com.bsd.pjcloud.supermarket.controller;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.supermarket.dto.SmHotelOrderCheckoutQueryDto;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderCheckoutQueryService;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 结账专用内部只读入口,与酒店现有履约和支付入口隔离。 */
@RestController
@RequiredArgsConstructor
@RequestMapping("/inner/sm-hotel-order-checkout-query")
public class SmHotelOrderCheckoutQueryController {
/** 仅执行数据库读取的查询服务。 */
private final SmHotelOrderCheckoutQueryService queryService;
/** 读取固定周期内一批酒店事实。 */
@Operation(summary = "读取固定周期内一批酒店事实")
@Inner @PostMapping("/scan")
public R<SmHotelOrderCheckoutQueryVo.ScanResult> scan(@Valid @RequestBody SmHotelOrderCheckoutQueryDto.Scan request) {
return R.ok(queryService.scan(request));
}
/** 批量读取当前事实,缺失项保留明确状态。 */
@Operation(summary = "批量读取当前事实,缺失项保留明确状态")
@Inner @PostMapping("/by-ids")
public R<List<SmHotelOrderCheckoutQueryVo>> byIds(@Valid @RequestBody SmHotelOrderCheckoutQueryDto.ByIds request) {
return R.ok(queryService.byIds(request));
}
}
@@ -0,0 +1,18 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.supermarket.dto.SmHotelOrderCheckoutQueryDto;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** 结账专用事实查询,租户由现有拦截器自动处理。 */
@Mapper
public interface SmHotelOrderCheckoutQueryMapper {
/** 读取当前筛选范围最大主键以固定扫描边界,空范围返回null。 */
Long upperId(@Param("query") SmHotelOrderCheckoutQueryDto.Scan request);
/** 按主键升序读取一批事实,归属歧义保留异常记录。 */
List<SmHotelOrderCheckoutQueryVo> scan(@Param("query") SmHotelOrderCheckoutQueryDto.Scan request);
/** 读取明确订单主键,软删除及物理缺失由服务层补为缺失结果。 */
List<SmHotelOrderCheckoutQueryVo> byIds(@Param("ids") List<Long> ids);
}
@@ -0,0 +1,13 @@
package com.bsd.pjcloud.supermarket.service;
import com.bsd.pjcloud.supermarket.dto.SmHotelOrderCheckoutQueryDto;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo;
import java.util.List;
/** 酒店结账事实读取接口,无订单更新及外部渠道调用。 */
public interface SmHotelOrderCheckoutQueryService {
/** 按稳定主键顺序读取周期内最多500笔。 */
SmHotelOrderCheckoutQueryVo.ScanResult scan(SmHotelOrderCheckoutQueryDto.Scan request);
/** 按请求顺序返回现状,重复主键去重,缺失项明确返回。 */
List<SmHotelOrderCheckoutQueryVo> byIds(SmHotelOrderCheckoutQueryDto.ByIds request);
}
@@ -0,0 +1,61 @@
package com.bsd.pjcloud.supermarket.service.impl;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.supermarket.dto.SmHotelOrderCheckoutQueryDto;
import com.bsd.pjcloud.supermarket.mapper.SmHotelOrderCheckoutQueryMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderCheckoutQueryService;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
/** 只读酒店结账查询,不调用含超时关单副作用的支付校验。 */
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public class SmHotelOrderCheckoutQueryServiceImpl implements SmHotelOrderCheckoutQueryService {
/** 独立的数据库只读访问。 */
private final SmHotelOrderCheckoutQueryMapper mapper;
/** 在当前事务中确定上界并读取一批事实。 */
@Override
public SmHotelOrderCheckoutQueryVo.ScanResult scan(SmHotelOrderCheckoutQueryDto.Scan request) {
requireTenant();
if (request.getPeriodStart().isAfter(request.getPeriodEnd())) throw new IllegalArgumentException("离店周期起止不合法");
if (request.getUpperId() == null) request.setUpperId(Optional.ofNullable(mapper.upperId(request)).orElse(0L));
SmHotelOrderCheckoutQueryVo.ScanResult result = new SmHotelOrderCheckoutQueryVo.ScanResult();
result.setUpperId(request.getUpperId());
result.setRecords(request.getUpperId() == 0 ? List.of() : mapper.scan(request));
return result;
}
/** 请求中不存在的主键返回明确缺失项,避免批量复核静默漏单。 */
@Override
public List<SmHotelOrderCheckoutQueryVo> byIds(SmHotelOrderCheckoutQueryDto.ByIds request) {
requireTenant();
List<Long> ids = request.getOrderIds().stream().distinct().toList();
Map<Long, SmHotelOrderCheckoutQueryVo> existing = mapper.byIds(ids).stream().collect(Collectors.toMap(SmHotelOrderCheckoutQueryVo::getOrderId, Function.identity()));
List<SmHotelOrderCheckoutQueryVo> result = new ArrayList<>();
for (Long id : ids) {
SmHotelOrderCheckoutQueryVo row = existing.get(id);
if (row == null) {
row = new SmHotelOrderCheckoutQueryVo();
row.setOrderId(id);
row.setFound(false);
row.setResolutionStatus("MISSING_ORDER");
row.setResolutionMessage("酒店订单已删除或不存在");
}
result.add(row);
}
return result;
}
/** 内部调用也必须具备有效租户上下文,避免空上下文扩大查询。 */
private void requireTenant() {
if (TenantContextHolder.getTenantId() == null || TenantContextHolder.getTenantId() <= 0) throw new IllegalStateException("缺少租户上下文");
}
}
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.supermarket.mapper.SmHotelOrderCheckoutQueryMapper">
<!-- 历史无hotel_id订单仅允许编码唯一匹配;有明确主键时始终使用该主键。 -->
<sql id="hotelFrom">
FROM canteen_hotel_order o
LEFT JOIN canteen_hotel h ON h.id = CASE WHEN o.hotel_id IS NOT NULL THEN o.hotel_id ELSE
(SELECT MIN(legacy.id) FROM canteen_hotel legacy
WHERE legacy.hotel_code = o.hotel_code AND legacy.del_flag = '0'
AND (o.group_code IS NULL OR o.group_code = '' OR legacy.group_code = o.group_code)
HAVING COUNT(*) = 1) END AND h.del_flag = '0'
</sql>
<sql id="columns">
o.id AS order_id, 1 AS found, o.channel_no, o.platform_no, h.id AS hotel_id,
h.hotel_name, h.dept_id, h.sheet_name AS hotel_data_source, o.partner_code, o.downstream_app_id,
o.check_in_date, o.check_out_date, o.count AS rooms, CAST(o.sell_amount AS CHAR) AS sell_amount_yuan,
o.order_status, o.book_status, o.pay_status, o.update_time,
CASE WHEN h.id IS NOT NULL THEN 'OK'
WHEN o.hotel_id IS NULL AND (SELECT COUNT(*) FROM canteen_hotel hc
WHERE hc.hotel_code = o.hotel_code AND hc.del_flag = '0'
AND (o.group_code IS NULL OR o.group_code = '' OR hc.group_code = o.group_code)) &gt; 1
THEN 'AMBIGUOUS_HOTEL' ELSE 'MISSING_HOTEL' END AS resolution_status,
CASE WHEN h.id IS NULL THEN '酒店已失效或编码无法唯一确定归属' ELSE NULL END AS resolution_message
</sql>
<sql id="scanWhere">
WHERE o.del_flag = '0' AND o.check_out_date BETWEEN #{query.periodStart} AND #{query.periodEnd}
AND (h.dept_id IN <foreach collection="query.deptIds" item="dept" open="(" separator="," close=")">#{dept}</foreach>
OR (o.hotel_id IS NULL AND h.id IS NULL AND EXISTS (
SELECT 1 FROM canteen_hotel possible WHERE possible.hotel_code = o.hotel_code AND possible.del_flag = '0'
AND (o.group_code IS NULL OR o.group_code = '' OR possible.group_code = o.group_code)
AND possible.dept_id IN <foreach collection="query.deptIds" item="dept" open="(" separator="," close=")">#{dept}</foreach>
)))
</sql>
<select id="upperId" resultType="java.lang.Long">
SELECT MAX(o.id) <include refid="hotelFrom"/> <include refid="scanWhere"/>
</select>
<select id="scan" resultType="com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo">
SELECT <include refid="columns"/> <include refid="hotelFrom"/> <include refid="scanWhere"/>
AND o.id &gt; #{query.afterId} AND o.id &lt;= #{query.upperId} ORDER BY o.id LIMIT #{query.size}
</select>
<select id="byIds" resultType="com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo">
SELECT <include refid="columns"/> <include refid="hotelFrom"/>
WHERE o.del_flag = '0' AND o.id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</select>
</mapper>
@@ -0,0 +1,58 @@
package com.bsd.pjcloud.wallet.api.constant.mch;
/** 结账状态协议,枚举名称即持久化编码。 */
public final class MchCheckoutBillStates {
/** 状态命名容器不创建实例。 */
private MchCheckoutBillStates() { }
/** 整单状态,由全部商户明细在事务中汇总。 */
public enum Bill {
/** 有待付款明细且尚未登记付款。 */
WAIT_PAY,
/** 部分商户已经付款,其余仍待付款。 */
PART_PAID,
/** 全部有效商户明细已经付款或零元结清。 */
FINISHED,
/** 全部有订单的商户明细已经作废。 */
VOID
}
/** 单商户状态,零元结清沿用PAID并记录0分实付。 */
public enum Merchant {
/** 当前关系成员在本单没有选择订单。 */
NO_ORDERS,
/** 等待全额付款或零元确认结清。 */
WAIT_PAY,
/** 已经登记全额付款或零元结清。 */
PAID,
/** 待付款明细已经作废并释放订单。 */
VOID
}
/** 源事实复核结果,原始金额快照持续保留。 */
public enum Check {
/** 当前源事实与原账一致。 */
NORMAL,
/** 当前事实已经产生影响结账的差异。 */
DIFF,
/** 源数据读取失败,结果待重试。 */
QUERY_FAILED,
/** 当前已付款差异已有财务处理记录。 */
HANDLED
}
/** 追加式审计事件类型。 */
public enum Action {
/** 创建完整结账单。 */
CREATE,
/** 登记商户付款或零元结清。 */
PAY,
/** 作废待付款商户明细。 */
VOID,
/** 人工发起整单复核。 */
RECHECK,
/** 源事实发生结账差异。 */
DIFF,
/** 源事实查询失败。 */
QUERY_FAILED,
/** 财务处理已付款差异。 */
HANDLE
}
}
@@ -0,0 +1,104 @@
package com.bsd.pjcloud.wallet.api.dto.mch;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 结账请求模型,服务端独立计算金额、权限、审计和租户字段。 */
public final class MchCheckoutBillRequests {
/** 请求命名容器不创建实例。 */
private MchCheckoutBillRequests() { }
/** 酒店候选范围,仅接受明确关系和离店周期。 */
@Data
public static class Candidate {
/** 当前有效结账关系主键。 */
@NotNull @Positive private Long relationId;
/** 页面读取的关系并发版本。 */
@NotNull @Min(0) private Integer relationVersion;
/** 离店日期起点,包含当天。 */
@NotNull private LocalDate periodStart;
/** 离店日期终点,包含当天。 */
@NotNull private LocalDate periodEnd;
}
/** 预览明确选择的订单,每笔订单均由操作者确认履约和收款。 */
@Data
public static class Preview {
/** 当前用户10分钟内的候选令牌。 */
@NotBlank @Size(max = 64) private String candidateToken;
/** 完整跨页订单主键集合,最多10000笔。 */
@NotEmpty @Size(max = 10000) private List<@NotNull @Positive Long> orderIds;
/** 每单抽成,单位元,最多两位小数,使用字符串精确解析。 */
@NotBlank @Pattern(regexp = "[0-9]{1,15}(\\.[0-9]{1,2})?") private String feePerOrderYuan;
/** 操作者明确确认所有所选订单已经实际履约完成。 */
@NotNull @AssertTrue private Boolean fulfillmentConfirmed;
/** 操作者明确确认所有所选订单款项已经核对。 */
@NotNull @AssertTrue private Boolean receiptConfirmed;
/** 本次结账备注。 */
@Size(max = 500) private String remark;
}
/** 创建只提交预览身份和稳定请求号,金额以服务端预览及复核为准。 */
@Data
public static class Create {
/** 服务端生成的10分钟预览令牌。 */
@NotBlank @Size(max = 64) private String previewToken;
/** 同一提交及其超时重试保持不变的幂等号。 */
@NotBlank @Size(max = 64) private String requestId;
}
/** 单商户全额线下付款登记,零元时表示确认结清。 */
@Data
public static class Payment {
/** 已读取的商户结账明细版本。 */
@NotNull @Min(0) private Integer version;
/** 本次付款登记的稳定幂等号。 */
@NotBlank @Size(max = 64) private String requestId;
/** 实际付款或零元结清时间。 */
@NotNull @PastOrPresent private LocalDateTime paymentTime;
/** 线下付款流水号,与凭证至少提供一种。 */
@Size(max = 100) private String paymentNo;
/** 付款凭证地址,最多9张。 */
@Size(max = 9) private List<@NotBlank @Size(max = 2048) String> paymentPics;
/** 付款登记备注。 */
@Size(max = 500) private String remark;
}
/** 作废一份待付款商户明细,保留原账并释放它的订单占用。 */
@Data
public static class VoidMerchant {
/** 已读取的商户结账明细版本。 */
@NotNull @Min(0) private Integer version;
/** 作废请求的稳定幂等号。 */
@NotBlank @Size(max = 64) private String requestId;
/** 作废业务原因。 */
@NotBlank @Size(max = 500) private String reason;
}
/** 已付款差异的财务处理登记,原始账单金额持续保留。 */
@Data
public static class Handle {
/** 本次处理请求的幂等号。 */
@NotBlank @Size(max = 64) private String requestId;
/** 操作者看到的差异摘要,用于拒绝处理期间新出现的差异。 */
@NotBlank @Size(min = 64, max = 64) private String observedFingerprint;
/** 财务核实与处理结果。 */
@NotBlank @Size(max = 500) private String remark;
/** 处理凭据地址,最多9张。 */
@Size(max = 9) private List<@NotBlank @Size(max = 2048) String> evidencePics;
}
/** 账单列表筛选,历史权限按商户快照检查。 */
@Data
public static class ListQuery {
/** 中间方名称快照模糊筛选。 */
@Size(max = 100) private String intermediaryName;
/** 结账单编号模糊筛选。 */
@Size(max = 64) private String billNo;
/** 账单当前状态,空值查询全部授权账单。 */
@Pattern(regexp = "WAIT_PAY|PART_PAID|FINISHED|VOID") private String state;
}
}
@@ -0,0 +1,39 @@
package com.bsd.pjcloud.wallet.api.dto.mch;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
/**
* 管理端保存商户场景的请求,记录由服务端按商户和场景唯一定位。
*/
@Data
@Schema(description = "商户场景配置请求")
public class MchSceneDto {
/** 待配置的商户 ID,必须处于当前登录用户的数据范围内。 */
@NotNull(message = "商户不能为空")
@Schema(description = "商户ID")
private Long mchId;
/** 场景持久化编码,首期仅允许 HOTEL_COMMISSION 酒店抽成。 */
@NotBlank(message = "场景不能为空")
@Schema(description = "场景编码", allowableValues = "HOTEL_COMMISSION")
private String sceneCode;
/** 是否开启,首期只接受 0 关闭、1 开启。 */
@NotBlank(message = "是否开启不能为空")
@Schema(description = "是否开启", allowableValues = {"0", "1"})
private String enabled;
/** 每单完成酒店订单的固定抽成金额,单位元;缺省保留原金额,首次保存缺省时为 0.00 元。 */
@DecimalMin(value = "0.00", message = "抽成金额不能小于0元")
@Digits(integer = 8, fraction = 2, message = "抽成金额最多8位整数和2位小数")
@Schema(description = "每单完成酒店订单的固定抽成金额(元)")
private BigDecimal commissionAmount;
}
@@ -0,0 +1,100 @@
package com.bsd.pjcloud.wallet.api.entity.mch;
import com.baomidou.mybatisplus.annotation.*;
import com.bsd.pjcloud.common.core.util.TenantTable;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** 商户结账账单,原始财务汇总及归属快照固定保存。 */
@Data
@TenantTable
@TableName("mch_checkout_bill")
public class MchCheckoutBillEntity {
/** 应用分配的雪花主键。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 结账单编号,使用业务前缀和雪花主键。 */
private String billNo;
/** 订单业务类型,当前为HOTEL。 */
private String sourceType;
/** 发起时的结账关系主键。 */
private Long relationId;
/** 发起时的结账关系版本。 */
private Integer relationVersion;
/** 发起时的中间方名称快照。 */
private String intermediaryName;
/** 离店周期开始日期,包含当天。 */
private LocalDate periodStart;
/** 离店周期结束日期,包含当天。 */
private LocalDate periodEnd;
/** 金额计算规则版本,当前为1。 */
private Integer ruleVersion;
/** 每笔订单抽成,单位分。 */
private Long feePerOrderFen;
/** 实际纳入账单的订单笔数。 */
private Integer orderCount;
/** 关系全部商户快照数量,含零订单商户。 */
private Integer merchantCount;
/** 原始订单金额合计,单位分。 */
private Long grossAmountFen;
/** 原始抽成合计,单位分。 */
private Long feeAmountFen;
/** 原始商户应付合计,单位分。 */
private Long payableAmountFen;
/** WAIT_PAY待付款、PART_PAID部分已付、FINISHED已结清、VOID已作废。 */
private String state;
/** 每次账单状态汇总更新时递增的并发版本。 */
private Integer version;
/** 创建请求幂等号,租户内唯一。 */
private String createRequestId;
/** 规范化创建内容的SHA256摘要。 */
private String requestHash;
/** 创建时的预览令牌,供缓存失效后幂等重试匹配。 */
private String previewToken;
/** 创建前最近一次酒店源数据复核时间。 */
private LocalDateTime sourceVerifiedAt;
/** 本次结账备注,最多500字。 */
private String remark;
/** 创建人账号。 */
@TableField(fill = FieldFill.INSERT)
private String createBy;
/** 创建时间,服务端时间。 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 最后修改人账号。 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
/** 最后修改时间,服务端时间。 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/** 所属租户,由平台上下文自动填写。 */
private Long tenantId;
}
@@ -0,0 +1,49 @@
package com.bsd.pjcloud.wallet.api.entity.mch;
import com.baomidou.mybatisplus.annotation.*;
import com.bsd.pjcloud.common.core.util.TenantTable;
import lombok.Data;
import java.time.LocalDateTime;
/** 结账业务追加式审计事件。 */
@Data
@TenantTable
@TableName("mch_checkout_bill_event")
public class MchCheckoutBillEventEntity {
/** 应用分配的雪花主键。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 事件所属账单主键。 */
private Long billId;
/** 商户明细主键,整单事件为空。 */
private Long billMerchantId;
/** 源订单主键,非订单事件为空。 */
private Long sourceOrderId;
/** CREATE、PAY、VOID、RECHECK、DIFF、QUERY_FAILED、HANDLE业务动作。 */
private String action;
/** 该动作的请求幂等号或系统事件号。 */
private String requestId;
/** 该动作请求摘要,供审计核对。 */
private String requestHash;
/** 业务依据和结果JSON,不包含凭据或酒店住客资料。 */
private String payload;
/** 创建人账号。 */
@TableField(fill = FieldFill.INSERT)
private String createBy;
/** 创建时间,服务端时间。 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 所属租户,由平台上下文自动填写。 */
private Long tenantId;
}
@@ -0,0 +1,102 @@
package com.bsd.pjcloud.wallet.api.entity.mch;
import com.baomidou.mybatisplus.annotation.*;
import com.bsd.pjcloud.common.core.util.TenantTable;
import lombok.Data;
import java.time.LocalDateTime;
/** 结账商户明细及完整关系权限快照。 */
@Data
@TenantTable
@TableName("mch_checkout_bill_merchant")
public class MchCheckoutBillMerchantEntity {
/** 应用分配的雪花主键。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 所属结账账单主键。 */
private Long billId;
/** 绑定商户主键快照。 */
private Long merchantId;
/** 绑定商户名称快照。 */
private String merchantName;
/** 发起时的商户直属部门,用于历史数据权限。 */
private Long deptId;
/** 发起时的部门名称快照。 */
private String deptName;
/** 商户创建人快照,用于本人数据权限。 */
private String merchantCreateBy;
/** 本次选择的订单笔数,零表示无结账订单。 */
private Integer orderCount;
/** 该商户原订单合计,单位分。 */
private Long grossAmountFen;
/** 该商户抽成合计,单位分。 */
private Long feeAmountFen;
/** 该商户应付,单位分。 */
private Long payableAmountFen;
/** NO_ORDERS无订单、WAIT_PAY待付款、PAID已付款或结清、VOID已作废。 */
private String state;
/** 付款和作废操作使用的并发版本。 */
private Integer version;
/** 实际线下付款时间,零元时为结清时间。 */
private LocalDateTime paymentTime;
/** 登记的全额实付,单位分,未付款为空。 */
private Long paidAmountFen;
/** 线下付款流水号,与凭证至少提供一种。 */
private String paymentNo;
/** 付款凭证地址JSON数组,最多9张。 */
private String paymentPics;
/** 付款备注,最多500字。 */
private String paymentRemark;
/** 付款请求幂等号。 */
private String paymentRequestId;
/** 付款请求规范化内容摘要。 */
private String paymentRequestHash;
/** 作废原因,最多500字。 */
private String voidReason;
/** 作废请求幂等号。 */
private String voidRequestId;
/** 作废请求规范化内容摘要。 */
private String voidRequestHash;
/** 创建人账号。 */
@TableField(fill = FieldFill.INSERT)
private String createBy;
/** 创建时间,服务端时间。 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 最后修改人账号。 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
/** 最后修改时间,服务端时间。 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/** 所属租户,由平台上下文自动填写。 */
private Long tenantId;
}
@@ -0,0 +1,133 @@
package com.bsd.pjcloud.wallet.api.entity.mch;
import com.baomidou.mybatisplus.annotation.*;
import com.bsd.pjcloud.common.core.util.TenantTable;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** 结账订单不可变金额快照及后续复核结果。 */
@Data
@TenantTable
@TableName("mch_checkout_bill_order")
public class MchCheckoutBillOrderEntity {
/** 应用分配的雪花主键。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 所属账单主键。 */
private Long billId;
/** 所属商户结账明细主键。 */
private Long billMerchantId;
/** 归属商户主键快照。 */
private Long merchantId;
/** 源业务类型,当前为HOTEL。 */
private String sourceType;
/** 酒店本地订单主键,唯一占用身份的一部分。 */
private Long sourceOrderId;
/** 唯一解析的酒店主键快照。 */
private Long hotelId;
/** 酒店名称快照。 */
private String hotelName;
/** 酒店直属归属部门快照。 */
private Long deptId;
/** 渠道订单号快照。 */
private String channelNo;
/** 上游订单号快照。 */
private String platformNo;
/** 源订单入住日期快照。 */
private LocalDate checkInDate;
/** 源订单离店日期快照。 */
private LocalDate checkOutDate;
/** 房间数量快照,仅供核对。 */
private Integer rooms;
/** 该笔订单售价,单位分。 */
private Long grossAmountFen;
/** 该笔抽成,单位分。 */
private Long feeAmountFen;
/** 该笔商户应付,单位分。 */
private Long payableAmountFen;
/** 酒店原始订单状态快照。 */
private String orderStatus;
/** 酒店原始预订状态快照。 */
private String bookStatus;
/** 酒店原始支付状态快照。 */
private String payStatus;
/** 源订单最后更新时间快照。 */
private LocalDateTime sourceUpdatedAt;
/** 源金额归属状态等关键事实的SHA256摘要。 */
private String sourceFingerprint;
/** 不含住客和联系人信息的酒店事实JSON快照。 */
private String sourceSnapshot;
/** 履约完成及收款核对人的管理用户主键。 */
private Long confirmedUserId;
/** 履约完成及收款核对人的账号。 */
private String confirmedBy;
/** 服务端记录的履约与收款人工确认时间。 */
private LocalDateTime confirmedAt;
/** NORMAL一致、DIFF差异、QUERY_FAILED查询失败、HANDLED人工处理。 */
private String lastCheckStatus;
/** 最近一次核对的可读结果。 */
private String lastCheckMessage;
/** 最近观察到的源事实摘要。 */
private String lastObservedFingerprint;
/** 已人工处理的差异摘要,新差异重新提醒。 */
private String handledFingerprint;
/** 最近一次复核尝试时间。 */
private LocalDateTime lastCheckedAt;
/** 最近一次成功读取源数据时间,失败时保留。 */
private LocalDateTime lastVerifiedAt;
/** 下次后台复核时间,作废后为空。 */
private LocalDateTime nextCheckAt;
/** 创建人账号。 */
@TableField(fill = FieldFill.INSERT)
private String createBy;
/** 创建时间,服务端时间。 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 最后修改人账号。 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
/** 最后修改时间,服务端时间。 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/** 所属租户,由平台上下文自动填写。 */
private Long tenantId;
}
@@ -0,0 +1,40 @@
package com.bsd.pjcloud.wallet.api.entity.mch;
import com.baomidou.mybatisplus.annotation.*;
import com.bsd.pjcloud.common.core.util.TenantTable;
import lombok.Data;
import java.time.LocalDateTime;
/** 当前有效结账订单占用,作废待付款明细时释放。 */
@Data
@TenantTable
@TableName("mch_checkout_order_claim")
public class MchCheckoutOrderClaimEntity {
/** 应用分配的雪花主键。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 源业务类型,当前为HOTEL。 */
private String sourceType;
/** 源订单主键,租户和类型下唯一占用。 */
private Long sourceOrderId;
/** 占用账单主键。 */
private Long billId;
/** 占用的商户结账明细主键。 */
private Long billMerchantId;
/** 创建人账号。 */
@TableField(fill = FieldFill.INSERT)
private String createBy;
/** 创建时间,服务端时间。 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 所属租户,由平台上下文自动填写。 */
private Long tenantId;
}
@@ -0,0 +1,78 @@
package com.bsd.pjcloud.wallet.api.entity.mch;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 商户场景配置,同租户、商户和场景仅保存一条记录,父店与分店独立配置。
*/
@Data
@TenantTable
@TableName("mch_scene")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "商户场景配置")
public class MchSceneEntity extends Model<MchSceneEntity> {
/** 配置主键,由 MyBatis-Plus 分配雪花 ID。 */
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "配置主键")
private Long id;
/** 配置所属商户 ID,来源于当前用户可访问的商户记录。 */
@Schema(description = "商户ID")
private Long mchId;
/** 场景持久化编码,首期使用 HOTEL_COMMISSION 表示酒店抽成。 */
@Schema(description = "场景编码")
private String sceneCode;
/** 是否开启该场景,持久化值为 0 关闭、1 开启,默认关闭。 */
@Schema(description = "是否开启")
private String enabled;
/** 每单完成酒店订单的固定抽成金额,单位元,最多两位小数,默认 0.00 元。 */
@Schema(description = "每单完成酒店订单的固定抽成金额(元)")
private BigDecimal commissionAmount;
/** 创建人账号,由公共字段填充器从登录上下文写入。 */
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建人")
private String createBy;
/** 创建时间,由公共字段填充器按服务端时区写入。 */
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建时间")
private LocalDateTime createTime;
/** 最后修改人账号,由公共字段填充器维护。 */
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "修改人")
private String updateBy;
/** 最后修改时间,由公共字段填充器按服务端时区维护。 */
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "修改时间")
private LocalDateTime updateTime;
/** 逻辑删除标记,0 有效、1 已删除,沿用平台通用表规范。 */
@TableLogic
@TableField(fill = FieldFill.INSERT)
@Schema(description = "逻辑删除标记")
private String delFlag;
/** 租户 ID,查询隔离和新增赋值由公共租户拦截器维护。 */
@Schema(description = "租户ID")
private Long tenantId;
}
@@ -0,0 +1,52 @@
package com.bsd.pjcloud.wallet.api.vo.mch;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** 候选或预览订单展示,原始源事实仅在钱包内部缓存和复核。 */
@Data
public class MchCheckoutBillCandidateOrderVo {
/** 酒店本地订单主键。 */
private Long orderId;
/** 对应关系内商户主键,归属歧义时为空。 */
private Long merchantId;
/** 商户名称。 */
private String merchantName;
/** 部门名称。 */
private String deptName;
/** 酒店主键。 */
private Long hotelId;
/** 酒店名称。 */
private String hotelName;
/** 渠道订单号。 */
private String channelNo;
/** 上游订单号。 */
private String platformNo;
/** 渠道编码或开放应用标识。 */
private String sourceLabel;
/** 入住日期。 */
private LocalDate checkInDate;
/** 离店日期。 */
private LocalDate checkOutDate;
/** 房间数,仅供展示。 */
private Integer rooms;
/** 原订单金额,单位分,异常金额为空。 */
private Long grossAmountFen;
/** 预览每笔抽成,单位分,候选阶段为空。 */
private Long feeAmountFen;
/** 预览每笔应付,单位分,候选阶段为空。 */
private Long payableAmountFen;
/** 酒店原始订单状态。 */
private String orderStatus;
/** 酒店原始支付状态,未知渠道编码保留原值。 */
private String payStatus;
/** 是否允许加入本次结账。 */
private boolean selectable;
/** 当前不可选择或待人工核对的原因。 */
private String message;
/** 是否已被其他有效账单占用。 */
private boolean claimed;
/** 最近读取的源更新时间,仅供核对。 */
private LocalDateTime sourceUpdatedAt;
}
@@ -0,0 +1,36 @@
package com.bsd.pjcloud.wallet.api.vo.mch;
import lombok.Data;
import java.time.LocalDateTime;
/** BillEvent的管理端独立展示模型,金额字段单位均为分。 */
@Data
public class MchCheckoutBillEventVo {
/** 应用分配的雪花主键。 */
private Long id;
/** 事件所属账单主键。 */
private Long billId;
/** 商户明细主键,整单事件为空。 */
private Long billMerchantId;
/** 源订单主键,非订单事件为空。 */
private Long sourceOrderId;
/** CREATE、PAY、VOID、RECHECK、DIFF、QUERY_FAILED、HANDLE业务动作。 */
private String action;
/** 该动作的请求幂等号或系统事件号。 */
private String requestId;
/** 业务依据和结果JSON,不包含凭据或酒店住客资料。 */
private String payload;
/** 创建人账号。 */
private String createBy;
/** 创建时间,服务端时间。 */
private LocalDateTime createTime;
}
@@ -0,0 +1,72 @@
package com.bsd.pjcloud.wallet.api.vo.mch;
import lombok.Data;
import java.time.LocalDateTime;
/** BillMerchant的管理端独立展示模型,金额字段单位均为分。 */
@Data
public class MchCheckoutBillMerchantVo {
/** 应用分配的雪花主键。 */
private Long id;
/** 所属结账账单主键。 */
private Long billId;
/** 绑定商户主键快照。 */
private Long merchantId;
/** 绑定商户名称快照。 */
private String merchantName;
/** 发起时的商户直属部门,用于历史数据权限。 */
private Long deptId;
/** 发起时的部门名称快照。 */
private String deptName;
/** 本次选择的订单笔数,零表示无结账订单。 */
private Integer orderCount;
/** 该商户原订单合计,单位分。 */
private Long grossAmountFen;
/** 该商户抽成合计,单位分。 */
private Long feeAmountFen;
/** 该商户应付,单位分。 */
private Long payableAmountFen;
/** NO_ORDERS无订单、WAIT_PAY待付款、PAID已付款或结清、VOID已作废。 */
private String state;
/** 付款和作废操作使用的并发版本。 */
private Integer version;
/** 实际线下付款时间,零元时为结清时间。 */
private LocalDateTime paymentTime;
/** 登记的全额实付,单位分,未付款为空。 */
private Long paidAmountFen;
/** 线下付款流水号,与凭证至少提供一种。 */
private String paymentNo;
/** 付款凭证地址JSON数组,最多9张。 */
private String paymentPics;
/** 付款备注,最多500字。 */
private String paymentRemark;
/** 作废原因,最多500字。 */
private String voidReason;
/** 创建人账号。 */
private String createBy;
/** 创建时间,服务端时间。 */
private LocalDateTime createTime;
/** 最后修改时间,服务端时间。 */
private LocalDateTime updateTime;
}
@@ -0,0 +1,109 @@
package com.bsd.pjcloud.wallet.api.vo.mch;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** BillOrder的管理端独立展示模型,金额字段单位均为分。 */
@Data
public class MchCheckoutBillOrderVo {
/** 当前商户结账状态,用于区分作废历史与可处理的已付款差异。 */
private String merchantState;
/** 应用分配的雪花主键。 */
private Long id;
/** 所属账单主键。 */
private Long billId;
/** 所属商户结账明细主键。 */
private Long billMerchantId;
/** 归属商户主键快照。 */
private Long merchantId;
/** 源业务类型,当前为HOTEL。 */
private String sourceType;
/** 酒店本地订单主键,唯一占用身份的一部分。 */
private Long sourceOrderId;
/** 唯一解析的酒店主键快照。 */
private Long hotelId;
/** 酒店名称快照。 */
private String hotelName;
/** 酒店直属归属部门快照。 */
private Long deptId;
/** 渠道订单号快照。 */
private String channelNo;
/** 上游订单号快照。 */
private String platformNo;
/** 源订单入住日期快照。 */
private LocalDate checkInDate;
/** 源订单离店日期快照。 */
private LocalDate checkOutDate;
/** 房间数量快照,仅供核对。 */
private Integer rooms;
/** 该笔订单售价,单位分。 */
private Long grossAmountFen;
/** 该笔抽成,单位分。 */
private Long feeAmountFen;
/** 该笔商户应付,单位分。 */
private Long payableAmountFen;
/** 酒店原始订单状态快照。 */
private String orderStatus;
/** 酒店原始预订状态快照。 */
private String bookStatus;
/** 酒店原始支付状态快照。 */
private String payStatus;
/** 源订单最后更新时间快照。 */
private LocalDateTime sourceUpdatedAt;
/** 履约完成及收款核对人的账号。 */
private String confirmedBy;
/** 服务端记录的履约与收款人工确认时间。 */
private LocalDateTime confirmedAt;
/** NORMAL一致、DIFF差异、QUERY_FAILED查询失败、HANDLED人工处理。 */
private String lastCheckStatus;
/** 最近一次核对的可读结果。 */
private String lastCheckMessage;
/** 最近观察到的源事实摘要。 */
private String lastObservedFingerprint;
/** 最近一次复核尝试时间。 */
private LocalDateTime lastCheckedAt;
/** 最近一次成功读取源数据时间,失败时保留。 */
private LocalDateTime lastVerifiedAt;
/** 下次后台复核时间,作废后为空。 */
private LocalDateTime nextCheckAt;
/** 创建人账号。 */
private String createBy;
/** 创建时间,服务端时间。 */
private LocalDateTime createTime;
/** 最后修改时间,服务端时间。 */
private LocalDateTime updateTime;
}
@@ -0,0 +1,43 @@
package com.bsd.pjcloud.wallet.api.vo.mch;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 当前用户的临时候选或确认预览概要,10分钟后需重新核对。 */
@Data
public class MchCheckoutBillSnapshotVo {
/** 临时候选或预览令牌,服务端校验所属用户。 */
private String token;
/** 结账关系主键。 */
private Long relationId;
/** 结账关系版本。 */
private Integer relationVersion;
/** 中间方名称。 */
private String intermediaryName;
/** 离店周期起点。 */
private LocalDate periodStart;
/** 离店周期终点。 */
private LocalDate periodEnd;
/** 令牌过期时间。 */
private LocalDateTime expiresAt;
/** 本次源数据读取或复核时间。 */
private LocalDateTime sourceVerifiedAt;
/** 全部订单数量,候选含异常和已占用订单。 */
private Integer orderCount;
/** 候选可加入订单数量。 */
private Integer selectableCount;
/** 全部关系商户快照,候选金额为零,预览包含所选订单汇总。 */
private List<MchCheckoutBillMerchantVo> merchants;
/** 原订单合计,单位分,候选阶段为零。 */
private Long grossAmountFen;
/** 每单抽成,单位分,候选阶段为零。 */
private Long feePerOrderFen;
/** 抽成合计,单位分。 */
private Long feeAmountFen;
/** 商户应付合计,单位分。 */
private Long payableAmountFen;
/** 本次结账备注。 */
private String remark;
}
@@ -0,0 +1,93 @@
package com.bsd.pjcloud.wallet.api.vo.mch;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** Bill的管理端独立展示模型,金额字段单位均为分。 */
@Data
public class MchCheckoutBillVo {
/** 应用分配的雪花主键。 */
private Long id;
/** 结账单编号,使用业务前缀和雪花主键。 */
private String billNo;
/** 订单业务类型,当前为HOTEL。 */
private String sourceType;
/** 发起时的结账关系主键。 */
private Long relationId;
/** 发起时的结账关系版本。 */
private Integer relationVersion;
/** 发起时的中间方名称快照。 */
private String intermediaryName;
/** 离店周期开始日期,包含当天。 */
private LocalDate periodStart;
/** 离店周期结束日期,包含当天。 */
private LocalDate periodEnd;
/** 金额计算规则版本,当前为1。 */
private Integer ruleVersion;
/** 每笔订单抽成,单位分。 */
private Long feePerOrderFen;
/** 实际纳入账单的订单笔数。 */
private Integer orderCount;
/** 关系全部商户快照数量,含零订单商户。 */
private Integer merchantCount;
/** 原始订单金额合计,单位分。 */
private Long grossAmountFen;
/** 原始抽成合计,单位分。 */
private Long feeAmountFen;
/** 原始商户应付合计,单位分。 */
private Long payableAmountFen;
/** WAIT_PAY待付款、PART_PAID部分已付、FINISHED已结清、VOID已作废。 */
private String state;
/** 每次账单状态汇总更新时递增的并发版本。 */
private Integer version;
/** 创建前最近一次酒店源数据复核时间。 */
private LocalDateTime sourceVerifiedAt;
/** 本次结账备注,最多500字。 */
private String remark;
/** 创建人账号。 */
private String createBy;
/** 创建时间,服务端时间。 */
private LocalDateTime createTime;
/** 最后修改时间,服务端时间。 */
private LocalDateTime updateTime;
/** 排除作废后的有效应付合计,单位分。 */
private Long effectivePayableAmountFen;
/** 已登记付款或结清的金额合计,单位分。 */
private Long paidAmountFen;
/** 待付款商户的应付合计,单位分。 */
private Long pendingAmountFen;
/** 已作废商户的原应付合计,单位分。 */
private Long voidAmountFen;
/** 当前已作废的商户明细数量。 */
private Integer voidMerchantCount;
/** 当前未处理差异或查询失败的订单数量。 */
private Integer anomalyCount;
}
@@ -0,0 +1,34 @@
package com.bsd.pjcloud.wallet.api.vo.mch;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/**
* 管理端查询和保存后的商户场景配置响应。
*/
@Data
@Schema(description = "商户场景配置响应")
public class MchSceneVo {
/** 服务端分配的场景配置主键。 */
@Schema(description = "配置主键")
private Long id;
/** 独立配置的商户 ID,父店与分店使用各自的 ID。 */
@Schema(description = "商户ID")
private Long mchId;
/** 场景持久化编码,HOTEL_COMMISSION 表示酒店抽成。 */
@Schema(description = "场景编码")
private String sceneCode;
/** 当前场景开关,0 关闭、1 开启。 */
@Schema(description = "是否开启")
private String enabled;
/** 每单完成酒店订单的固定抽成金额,单位元,最多两位小数,0 表示金额为零。 */
@Schema(description = "每单完成酒店订单的固定抽成金额(元)")
private BigDecimal commissionAmount;
}
@@ -78,6 +78,12 @@
<artifactId>pjcloud-hardware-api</artifactId>
<version>5.7.0</version>
</dependency>
<!-- 酒店结账仅通过独立只读Feign契约获取源订单事实。 -->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-supermarket-api</artifactId>
<version>5.7.0</version>
</dependency>
<!--必备: log 依赖-->
<dependency>
<groupId>com.bsd</groupId>
@@ -0,0 +1,163 @@
package com.bsd.pjcloud.wallet.controller.mch;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutBillRequests;
import com.bsd.pjcloud.wallet.api.vo.mch.*;
import com.bsd.pjcloud.wallet.service.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import lombok.RequiredArgsConstructor;
import io.swagger.v3.oas.annotations.Operation;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 钱包商户结账管理入口,功能权限和完整数据权限分别终审。 */
@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/mch-checkout-bill")
@Tag(name = "商户结账账单")
public class MchCheckoutBillController {
/** 账单事务和历史查询。 */
private final MchCheckoutBillService bills;
/** 酒店只读候选及金额预览。 */
private final MchCheckoutBillPreviewService previews;
/** 源事实复核及差异持久化。 */
private final MchCheckoutBillRecheckService rechecks;
/** 按完整关系查询酒店候选,失败不能作为空结果。 */
@Operation(summary = "按完整关系查询酒店候选,失败不能作为空结果")
@PostMapping("/hotel/candidates")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_add')")
public R<MchCheckoutBillSnapshotVo> candidates(@Valid @RequestBody MchCheckoutBillRequests.Candidate request) {
return R.ok(previews.candidates(request));
}
/** 当前用户候选工作集内筛选后分页,显示不可选原因。 */
@Operation(summary = "当前用户候选工作集内筛选后分页,显示不可选原因")
@GetMapping("/hotel/candidates/{token}/orders")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_add')")
public R<Page<MchCheckoutBillCandidateOrderVo>> candidateOrders(@PathVariable String token, @ParameterObject Page<MchCheckoutBillCandidateOrderVo> page,
@RequestParam(required = false) Long merchantId, @RequestParam(required = false) String keyword, @RequestParam(defaultValue = "false") boolean selectableOnly) {
return R.ok(previews.orders(token, false, page, merchantId, keyword, selectableOnly));
}
/** 返回筛选范围完整可选主键,用于明确的跨页批量加入。 */
@Operation(summary = "返回筛选范围完整可选主键,用于明确的跨页批量加入")
@GetMapping("/hotel/candidates/{token}/selectable-ids")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_add')")
public R<List<Long>> selectableIds(@PathVariable String token, @RequestParam(required = false) Long merchantId, @RequestParam(required = false) String keyword) {
return R.ok(previews.selectableIds(token, merchantId, keyword));
}
/** 复核明确名单及人工确认后计算逐商户和逐单金额。 */
@Operation(summary = "复核明确名单及人工确认后计算逐商户和逐单金额")
@PostMapping("/hotel/preview")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_add')")
public R<MchCheckoutBillSnapshotVo> preview(@Valid @RequestBody MchCheckoutBillRequests.Preview request) {
return R.ok(previews.preview(request));
}
/** 按当前用户有效预览分页读取完整商户金额。 */
@Operation(summary = "按当前用户有效预览分页读取完整商户金额")
@GetMapping("/preview/{token}/merchants")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_add')")
public R<Page<MchCheckoutBillMerchantVo>> previewMerchants(@PathVariable String token, @ParameterObject Page<MchCheckoutBillMerchantVo> page) {
return R.ok(previews.merchants(token, page));
}
/** 按当前用户有效预览分页读取逐单抽成和应付。 */
@Operation(summary = "按当前用户有效预览分页读取逐单抽成和应付")
@GetMapping("/preview/{token}/orders")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_add')")
public R<Page<MchCheckoutBillCandidateOrderVo>> previewOrders(@PathVariable String token, @ParameterObject Page<MchCheckoutBillCandidateOrderVo> page, @RequestParam(required = false) Long merchantId) {
return R.ok(previews.orders(token, true, page, merchantId, null, false));
}
/** 创建本地完整账单与唯一占用,响应重试按请求号恢复。 */
@Operation(summary = "创建本地完整账单与唯一占用,响应重试按请求号恢复")
@PostMapping("/hotel") @SysLog("发起酒店商户结账")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_add')")
public R<MchCheckoutBillVo> create(@Valid @RequestBody MchCheckoutBillRequests.Create request) {
return R.ok(bills.create(request));
}
/** 账单列表在分页之前过滤整组历史快照权限。 */
@Operation(summary = "账单列表在分页之前过滤整组历史快照权限")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_view')")
public R<Page<MchCheckoutBillVo>> page(@ParameterObject Page<MchCheckoutBillVo> page, @Valid @ParameterObject MchCheckoutBillRequests.ListQuery query) {
return R.ok(bills.page(page, query));
}
/** 读取历史账单原始金额及有效应付、实付和异常数量。 */
@Operation(summary = "读取历史账单原始金额及有效应付、实付和异常数量")
@GetMapping("/{id}")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_view')")
public R<MchCheckoutBillVo> detail(@PathVariable @Positive Long id) {
return R.ok(bills.detail(id));
}
/** 查询账单商户明细,含无订单商户快照。 */
@Operation(summary = "查询账单商户明细,含无订单商户快照")
@GetMapping("/{id}/merchants")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_view')")
public R<Page<MchCheckoutBillMerchantVo>> merchants(@PathVariable @Positive Long id, @ParameterObject Page<MchCheckoutBillMerchantVo> page) {
return R.ok(bills.merchants(id, page));
}
/** 查询逐单金额和后续复核状态。 */
@Operation(summary = "查询逐单金额和后续复核状态")
@GetMapping("/{id}/orders")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_view')")
public R<Page<MchCheckoutBillOrderVo>> orders(@PathVariable @Positive Long id, @ParameterObject Page<MchCheckoutBillOrderVo> page,
@RequestParam(required = false) Long merchantId, @RequestParam(defaultValue = "false") boolean anomaliesOnly) {
return R.ok(bills.orders(id, merchantId, anomaliesOnly, page));
}
/** 查询业务追加式审计轨迹。 */
@Operation(summary = "查询业务追加式审计轨迹")
@GetMapping("/{id}/events")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_view')")
public R<Page<MchCheckoutBillEventVo>> events(@PathVariable @Positive Long id, @ParameterObject Page<MchCheckoutBillEventVo> page) {
return R.ok(bills.events(id, page));
}
/** 复核后登记指定商户全额付款或零元结清。 */
@Operation(summary = "复核后登记指定商户全额付款或零元结清")
@PostMapping("/{id}/merchants/{merchantId}/confirm-payment") @SysLog("登记商户结账付款")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_pay')")
public R<MchCheckoutBillVo> pay(@PathVariable @Positive Long id, @PathVariable @Positive Long merchantId, @Valid @RequestBody MchCheckoutBillRequests.Payment request) {
return R.ok(bills.pay(id, merchantId, request));
}
/** 作废指定待付款商户明细并释放它的占用。 */
@Operation(summary = "作废指定待付款商户明细并释放它的占用")
@PostMapping("/{id}/merchants/{merchantId}/void") @SysLog("作废商户结账明细")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_void')")
public R<MchCheckoutBillVo> voidMerchant(@PathVariable @Positive Long id, @PathVariable @Positive Long merchantId, @Valid @RequestBody MchCheckoutBillRequests.VoidMerchant request) {
return R.ok(bills.voidMerchant(id, merchantId, request));
}
/** 人工重新读取源事实并持久化差异。 */
@Operation(summary = "人工重新读取源事实并持久化差异")
@PostMapping("/{id}/recheck") @SysLog("复核商户结账订单")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_recheck')")
public R<MchCheckoutBillVo> recheck(@PathVariable @Positive Long id) {
return R.ok(rechecks.recheck(id));
}
/** 已付款差异登记处理依据,原始账单金额保留。 */
@Operation(summary = "已付款差异登记处理依据,原始账单金额保留")
@PostMapping("/{id}/orders/{orderId}/handle") @SysLog("处理商户结账差异")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_handle')")
public R<MchCheckoutBillVo> handle(@PathVariable @Positive Long id, @PathVariable @Positive Long orderId, @Valid @RequestBody MchCheckoutBillRequests.Handle request) {
return R.ok(bills.handle(id, orderId, request));
}
}
@@ -0,0 +1,63 @@
package com.bsd.pjcloud.wallet.controller.mch;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.wallet.api.dto.mch.MchSceneDto;
import com.bsd.pjcloud.wallet.api.vo.mch.MchSceneVo;
import com.bsd.pjcloud.wallet.service.MchSceneService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 管理端商户场景配置接口,读写共用既有商户设置权限。
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/mch-scene")
@Tag(description = "mch-scene", name = "商户场景配置")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class MchSceneController {
/** 商户场景服务,负责商户数据范围和配置保存规则。 */
private final MchSceneService sceneService;
/**
* 查询当前用户可访问商户的指定场景配置。
*
* @param mchId 商户 ID
* @param sceneCode 场景持久化编码
* @return 商户场景配置,未配置时返回空数据
*/
@GetMapping("/mch/{mchId}")
@Operation(summary = "按商户查询场景配置")
@PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
public R<MchSceneVo> getForManagement(@PathVariable Long mchId, @RequestParam String sceneCode) {
return R.ok(sceneService.getForManagement(mchId, sceneCode));
}
/**
* 按商户和场景新增或更新配置,记录主键及租户信息由服务端维护。
*
* @param dto 待保存的商户场景开关及固定抽成金额
* @return 保存后的配置
*/
@PostMapping
@Operation(summary = "保存商户场景配置")
@SysLog("保存商户场景配置")
@PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
public R<MchSceneVo> saveOrUpdate(@Valid @RequestBody MchSceneDto dto) {
return R.ok(sceneService.saveOrUpdateConfig(dto));
}
}
@@ -0,0 +1,62 @@
package com.bsd.pjcloud.wallet.job;
import com.bsd.pjcloud.admin.api.entity.SysTenant;
import com.bsd.pjcloud.admin.api.feign.RemoteTenantService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.wallet.service.MchCheckoutBillRecheckService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.*;
/** 钱包侧酒店账单分批复核,按现有租户上下文切换,历史已付款订单持续参与。 */
@Slf4j
@Component
@RequiredArgsConstructor
public class MchCheckoutBillRecheckJob {
/** 自动租户命名空间内的后台互斥业务Key。 */
private static final String LOCK_KEY = "mch-checkout:hotel:recheck-job";
/** 按持有者释放,避免误删过期后其他实例持有的锁。 */
private static final DefaultRedisScript<Long> RELEASE = new DefaultRedisScript<>("if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end", Long.class);
/** 现有有效租户查询。 */
private final RemoteTenantService tenants;
/** 自动租户隔离的Redis组件。 */
private final StringRedisTemplate redis;
/** 单租户最大500条的复核能力。 */
private final MchCheckoutBillRecheckService rechecks;
/** 每分钟扫描一次,各租户内最多处理500条到期订单。 */
@Scheduled(fixedDelay = 60000L, initialDelay = 60000L)
public void process() {
try {
R<List<SysTenant>> response = tenants.list();
if (response == null || !response.isOk() || response.getData() == null) throw new IllegalStateException("结账复核租户目录读取失败");
for (SysTenant tenant : response.getData()) {
try {
if (tenant == null || tenant.getId() == null || tenant.getId() <= 0) throw new IllegalStateException("结账复核租户身份无效");
TenantBroker.runAs(tenant.getId(), ignored -> processTenant());
} catch (RuntimeException exception) {
log.error("结账后台复核租户执行失败", exception);
}
}
} catch (RuntimeException exception) {
log.error("结账后台复核扫描失败", exception);
}
}
/** 使用带过期和持有者校验的锁防止同租户多实例重复扫描。 */
private void processTenant() {
String owner = UUID.randomUUID().toString();
if (!Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(LOCK_KEY, owner, Duration.ofMinutes(5)))) return;
try {
rechecks.checkDue();
} finally {
redis.execute(RELEASE, List.of(LOCK_KEY), owner);
}
}
}
@@ -4,6 +4,7 @@ import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Constants;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.data.datascope.DataScope;
@@ -20,6 +21,7 @@ import com.github.yulichang.wrapper.MPJLambdaWrapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
@@ -202,6 +204,34 @@ public interface WalletManageMapper extends PjcloudBaseMapper<WalletManageEntity
return list;
}
/**
* 复用钱包通用筛选条件,查询按全部匹配钱包月末余额排序后的当前页。
*
* @param page 已设置可信余额排序、主键排序并关闭自动计数的分页参数
* @param dto 已完成身份用户预筛选的钱包条件
* @param monthEndExclusive 统计月份下月首日零点,流水查询使用排他时间上界
* @param dataScope 当前操作者的数据权限范围
* @return 月末余额分页;没有有效流水的钱包余额为零
*/
default Page<WalletManageVo> scopeQbBalancePage(Page<WalletManageVo> page, WalletManageDto dto, LocalDateTime monthEndExclusive, DataScope dataScope) {
LambdaQueryWrapper<WalletManageEntity> wrapper = commonQuery(dto);
// 仅清除本次包装器的默认顺序,由分页插件在数据权限外层应用余额排序。
wrapper.getExpression().getOrderBy().clear();
return selectQbBalancePage(page, dto, monthEndExclusive, wrapper, dataScope);
}
/**
* 单次窗口计算用户与钱包类型的月末最新流水,再按钱包筛选、权限及分页参数返回记录。
*
* @param page 可信排序及分页参数,调用方负责独立统计钱包总数
* @param dto 身份预筛选后的条件,用于提前缩小流水的用户和钱包类型范围
* @param monthEndExclusive 流水时间的排他上界
* @param wrapper 仅由 commonQuery 生成、已清除默认排序的钱包筛选条件
* @param dataScope 由统一拦截器应用的数据权限范围
* @return 每个钱包最多一行的月末余额分页,不修改钱包或流水数据
*/
Page<WalletManageVo> selectQbBalancePage(Page<WalletManageVo> page, @Param("dto") WalletManageDto dto, @Param("monthEndExclusive") LocalDateTime monthEndExclusive, @Param(Constants.WRAPPER) LambdaQueryWrapper<WalletManageEntity> wrapper, @Param("dataScope") DataScope dataScope);
default Long count(WalletManageDto dto, DataScope dataScope) {
return selectCountByScope(commonQuery(dto), dataScope);
}
@@ -215,7 +245,14 @@ public interface WalletManageMapper extends PjcloudBaseMapper<WalletManageEntity
List<Long> scopeUserList(@Param("dto") WalletManageDto dto, @Param("dataScope") DataScope dataScope);
List<WalletManageBalanceVo> selectBalance(@Param("dto") WalletManageBalanceDto walletDto);
/**
* 查询指定用户和钱包类型在月末之前的最新有效流水余额,与全量余额排序共用取值规则。
*
* @param walletDto 用户及钱包类型筛选条件,通常限定为当前页用户
* @param monthEndExclusive 统计月份下月首日零点,按流水时间和主键倒序选择唯一最新记录
* @return 每个用户与钱包类型最多一条余额记录;无有效流水时不返回记录
*/
List<WalletManageBalanceVo> selectBalance(@Param("dto") WalletManageBalanceDto walletDto, @Param("monthEndExclusive") LocalDateTime monthEndExclusive);
Page<WalletManageVo> activeBalancePage(Page page, @Param("dto") WalletTopupActionDto dto, @Param("unactiveDayNumberStr") String unactiveDayNumberStr);
@@ -0,0 +1,10 @@
package com.bsd.pjcloud.wallet.mapper.mch;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutBillEventEntity;
import org.apache.ibatis.annotations.Mapper;
/** BillEvent数据访问,全部SQL通过平台自动隔离租户。 */
@Mapper
public interface MchCheckoutBillEventMapper extends PjcloudBaseMapper<MchCheckoutBillEventEntity> {
}
@@ -0,0 +1,21 @@
package com.bsd.pjcloud.wallet.mapper.mch;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutBillEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutBillRequests;
import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutBillVo;
import com.bsd.pjcloud.wallet.support.mch.MchCheckoutRelationScope;
/** Bill数据访问,全部SQL通过平台自动隔离租户。 */
@Mapper
public interface MchCheckoutBillMapper extends PjcloudBaseMapper<MchCheckoutBillEntity> {
/** 在分页前按完整商户权限快照过滤,按创建时间和主键倒序。 */
Page<MchCheckoutBillVo> pageVisible(Page<MchCheckoutBillVo> page, @Param("query") MchCheckoutBillRequests.ListQuery query, @Param("scope") MchCheckoutRelationScope scope);
/** 读取整单可见的历史账单及派生金额,无权和不存在统一返回null。 */
MchCheckoutBillVo findVisible(@Param("id") Long id, @Param("scope") MchCheckoutRelationScope scope);
/** 锁定账单主体,付款、作废及异常登记统一先锁主体。 */
MchCheckoutBillEntity lockBill(@Param("id") Long id);
}
@@ -0,0 +1,18 @@
package com.bsd.pjcloud.wallet.mapper.mch;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutBillMerchantEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** BillMerchant数据访问,全部SQL通过平台自动隔离租户。 */
@Mapper
public interface MchCheckoutBillMerchantMapper extends PjcloudBaseMapper<MchCheckoutBillMerchantEntity> {
/** 锁定账单下指定商户明细,唯一键保证单行。 */
MchCheckoutBillMerchantEntity lockMerchant(@Param("billId") Long billId, @Param("merchantId") Long merchantId);
/** 查找参与关系绑定的部门归属歧义,返回首个部门ID,空表示唯一。 */
Long ambiguousDepartment(@Param("deptIds") List<Long> deptIds);
/** 同一本地事务内批量插入明确行,单批最多500行,租户列由平台逐行补充。 */
int insertRows(@Param("rows") List<MchCheckoutBillMerchantEntity> rows);
}
@@ -0,0 +1,16 @@
package com.bsd.pjcloud.wallet.mapper.mch;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutBillOrderEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** BillOrder数据访问,全部SQL通过平台自动隔离租户。 */
@Mapper
public interface MchCheckoutBillOrderMapper extends PjcloudBaseMapper<MchCheckoutBillOrderEntity> {
/** 在主体锁之后锁定一条账单订单,用于差异处理及状态更新。 */
MchCheckoutBillOrderEntity lockOrder(@Param("billId") Long billId, @Param("id") Long id);
/** 同一本地事务内批量插入明确行,单批最多500行,租户列由平台逐行补充。 */
int insertRows(@Param("rows") List<MchCheckoutBillOrderEntity> rows);
}
@@ -0,0 +1,14 @@
package com.bsd.pjcloud.wallet.mapper.mch;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutOrderClaimEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** OrderClaim数据访问,全部SQL通过平台自动隔离租户。 */
@Mapper
public interface MchCheckoutOrderClaimMapper extends PjcloudBaseMapper<MchCheckoutOrderClaimEntity> {
/** 同一本地事务内批量插入明确行,单批最多500行,租户列由平台逐行补充。 */
int insertRows(@Param("rows") List<MchCheckoutOrderClaimEntity> rows);
}
@@ -0,0 +1,26 @@
package com.bsd.pjcloud.wallet.mapper.mch;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.wallet.api.entity.mch.MchSceneEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 商户场景 Mapper,复用公共租户拦截器和逻辑删除规则。
*/
@Mapper
public interface MchSceneMapper extends PjcloudBaseMapper<MchSceneEntity> {
/**
* 按商户和场景查询当前租户的有效配置,唯一键保证最多返回一条。
*
* @param mchId 商户 ID
* @param sceneCode 场景持久化编码
* @return 匹配配置,未配置或已逻辑删除时返回空
*/
default MchSceneEntity selectExact(Long mchId, String sceneCode) {
return selectOne(new LambdaQueryWrapper<MchSceneEntity>()
.eq(MchSceneEntity::getMchId, mchId)
.eq(MchSceneEntity::getSceneCode, sceneCode));
}
}
@@ -0,0 +1,216 @@
package com.bsd.pjcloud.wallet.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutBillRequests;
import com.bsd.pjcloud.wallet.api.entity.mch.*;
import com.bsd.pjcloud.wallet.api.vo.mch.*;
import com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutOrderClaimMapper;
import com.bsd.pjcloud.wallet.support.mch.*;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
/** 候选与确认预览服务,完整名单先计算后分页,临时预览不占用订单。 */
@Service
@RequiredArgsConstructor
public class MchCheckoutBillPreviewService {
/** 完整关系及商户部门快照。 */
private final MchCheckoutBillContext context;
/** 本人临时工作集。 */
private final MchCheckoutBillCache cache;
/** 酒店只读事实适配。 */
private final MchCheckoutBillHotelSource source;
/** 当前有效结账占用,预览时只读。 */
private final MchCheckoutOrderClaimMapper claims;
/** 完整读取周期候选,包含不可选项,再提供分页。 */
public MchCheckoutBillSnapshotVo candidates(MchCheckoutBillRequests.Candidate request) {
if (request.getPeriodStart().isAfter(request.getPeriodEnd())) throw new IllegalArgumentException("离店周期起止不合法");
MchCheckoutBillSnapshot snapshot = context.read(request.getRelationId(), request.getRelationVersion());
snapshot.setPeriodStart(request.getPeriodStart());
snapshot.setPeriodEnd(request.getPeriodEnd());
snapshot.setFeePerOrderFen(0L);
snapshot.setOrders(source.scan(snapshot.getMerchants().stream().map(MchCheckoutBillMerchantEntity::getDeptId).toList(), request.getPeriodStart(), request.getPeriodEnd()));
snapshot.setSourceVerifiedAt(LocalDateTime.now());
snapshot.setPreview(false);
cache.save(snapshot);
return summary(snapshot);
}
/** 逐项复核所选候选和人工确认,生成可提交的金额及归属快照。 */
public MchCheckoutBillSnapshotVo preview(MchCheckoutBillRequests.Preview request) {
if (!Boolean.TRUE.equals(request.getFulfillmentConfirmed()) || !Boolean.TRUE.equals(request.getReceiptConfirmed())) throw new IllegalArgumentException("请明确确认所选订单已履约完成且款项已核对");
MchCheckoutBillSnapshot original = require(request.getCandidateToken(), false);
List<Long> ids = request.getOrderIds().stream().distinct().sorted().toList();
if (ids.isEmpty() || ids.size() != request.getOrderIds().size() || ids.size() > MchCheckoutBillHotelSource.MAX_ORDERS) throw new IllegalArgumentException("结账订单名单为空、重复或超过10000笔");
Map<Long, SmHotelOrderCheckoutQueryVo> candidateIndex = original.getOrders().stream().collect(Collectors.toMap(SmHotelOrderCheckoutQueryVo::getOrderId, Function.identity()));
if (!candidateIndex.keySet().containsAll(ids)) throw new IllegalArgumentException("所选订单超出当前候选范围,请重新查询");
Map<Long, SmHotelOrderCheckoutQueryVo> fresh = source.fetch(ids);
for (Long id : ids) {
if (!source.fingerprint(candidateIndex.get(id)).equals(source.fingerprint(fresh.get(id)))) throw new IllegalStateException("订单" + id + "在查询后发生变化,请重新查询并核对");
}
MchCheckoutBillSnapshot snapshot = context.read(original.getRelationId(), original.getRelationVersion());
snapshot.setPeriodStart(original.getPeriodStart());
snapshot.setPeriodEnd(original.getPeriodEnd());
snapshot.setFeePerOrderFen(MchCheckoutBillMoney.fen(request.getFeePerOrderYuan(), "每单抽成"));
snapshot.setRemark(request.getRemark() == null ? "" : request.getRemark().trim());
snapshot.setOrders(ids.stream().map(fresh::get).toList());
snapshot.setSourceVerifiedAt(LocalDateTime.now());
snapshot.setPreview(true);
Set<Long> occupied = claimed(ids);
Map<Long, MchCheckoutBillMerchantEntity> byDept = snapshot.getMerchants().stream().collect(Collectors.toMap(MchCheckoutBillMerchantEntity::getDeptId, Function.identity()));
for (SmHotelOrderCheckoutQueryVo order : snapshot.getOrders()) {
MchCheckoutBillMerchantEntity member = byDept.get(order.getDeptId());
String reason = source.eligibility(order);
if (member == null || !reason.isEmpty() || occupied.contains(order.getOrderId())) throw new IllegalStateException("所选订单归属、状态或结账占用已变化,请重新查询");
long gross = MchCheckoutBillMoney.fen(order.getSellAmountYuan(), "订单金额");
long fee = snapshot.getFeePerOrderFen();
long payable = MchCheckoutBillMoney.payable(gross, fee);
member.setOrderCount(Math.addExact(member.getOrderCount(), 1));
member.setGrossAmountFen(MchCheckoutBillMoney.add(member.getGrossAmountFen(), gross));
member.setFeeAmountFen(MchCheckoutBillMoney.add(member.getFeeAmountFen(), fee));
member.setPayableAmountFen(MchCheckoutBillMoney.add(member.getPayableAmountFen(), payable));
member.setState("WAIT_PAY");
}
snapshot.setRequestHash(contentHash(snapshot));
// 在写缓存前计算全部总额,使整单溢出也在预览阶段被拒绝。
summary(snapshot);
cache.save(snapshot);
return summary(snapshot);
}
/** 预览和创建共用的规范化内容摘要,校验缓存中的计费参数及源事实。 */
public String contentHash(MchCheckoutBillSnapshot snapshot) {
return source.hash(Arrays.asList(snapshot.getRelationId(), snapshot.getRelationVersion(), snapshot.getIntermediaryName(),
context.membershipHash(snapshot.getMerchants()), snapshot.getPeriodStart(), snapshot.getPeriodEnd(), snapshot.getFeePerOrderFen(),
snapshot.getRemark(), snapshot.getOrders().stream().map(source::fingerprint).toList()));
}
/** 当前用户读取工作集时重新检查关系及完整商户权限。 */
public MchCheckoutBillSnapshot require(String token, boolean preview) {
MchCheckoutBillSnapshot snapshot = cache.require(token, preview);
context.validateSnapshot(snapshot);
return snapshot;
}
/** 工作集完整订单先映射和筛选,之后分页,总数保持一致。 */
public Page<MchCheckoutBillCandidateOrderVo> orders(String token, boolean preview, Page<MchCheckoutBillCandidateOrderVo> page, Long merchantId, String keyword, boolean selectableOnly) {
MchCheckoutBillSnapshot snapshot = require(token, preview);
Set<Long> occupied = claimed(snapshot.getOrders().stream().map(SmHotelOrderCheckoutQueryVo::getOrderId).toList());
Map<Long, MchCheckoutBillMerchantEntity> byDept = snapshot.getMerchants().stream().collect(Collectors.toMap(MchCheckoutBillMerchantEntity::getDeptId, Function.identity()));
String search = keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT);
List<MchCheckoutBillCandidateOrderVo> rows = snapshot.getOrders().stream().map(row -> orderVo(row, byDept.get(row.getDeptId()), occupied, snapshot.isPreview() ? snapshot.getFeePerOrderFen() : null))
.filter(row -> merchantId == null || Objects.equals(row.getMerchantId(), merchantId))
.filter(row -> !selectableOnly || row.isSelectable())
.filter(row -> search.isEmpty() || (row.getOrderId() + " " + row.getHotelName() + " " + row.getChannelNo() + " " + row.getMerchantName()).toLowerCase(Locale.ROOT).contains(search)).toList();
return slice(page, rows);
}
/** 全量商户金额已在预览阶段计算,展示时只做分页。 */
public Page<MchCheckoutBillMerchantVo> merchants(String token, Page<MchCheckoutBillMerchantVo> page) {
return slice(page, require(token, true).getMerchants().stream().map(row -> copy(row, MchCheckoutBillMerchantVo.class)).toList());
}
/** 完整选中列表用于明确的跨页批量加入,容量仍由工作集上限约束。 */
public List<Long> selectableIds(String token, Long merchantId, String keyword) {
MchCheckoutBillSnapshot snapshot = require(token, false);
Set<Long> occupied = claimed(snapshot.getOrders().stream().map(SmHotelOrderCheckoutQueryVo::getOrderId).toList());
Map<Long, MchCheckoutBillMerchantEntity> byDept = snapshot.getMerchants().stream().collect(Collectors.toMap(MchCheckoutBillMerchantEntity::getDeptId, Function.identity()));
String search = keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT);
return snapshot.getOrders().stream().map(row -> orderVo(row, byDept.get(row.getDeptId()), occupied, null))
.filter(MchCheckoutBillCandidateOrderVo::isSelectable).filter(row -> merchantId == null || Objects.equals(row.getMerchantId(), merchantId))
.filter(row -> search.isEmpty() || (row.getOrderId() + " " + row.getHotelName() + " " + row.getChannelNo() + " " + row.getMerchantName()).toLowerCase(Locale.ROOT).contains(search))
.map(MchCheckoutBillCandidateOrderVo::getOrderId).toList();
}
/** 当前占用按明确主键分批读取,空名单不生成无条件查询。 */
public Set<Long> claimed(List<Long> ids) {
Set<Long> occupied = new HashSet<>();
for (int start = 0; start < ids.size(); start += MchCheckoutBillHotelSource.BATCH_SIZE) {
List<Long> batch = ids.subList(start, Math.min(ids.size(), start + MchCheckoutBillHotelSource.BATCH_SIZE));
claims.selectList(Wrappers.<MchCheckoutOrderClaimEntity>lambdaQuery().eq(MchCheckoutOrderClaimEntity::getSourceType, "HOTEL").in(MchCheckoutOrderClaimEntity::getSourceOrderId, batch))
.forEach(row -> occupied.add(row.getSourceOrderId()));
}
return occupied;
}
/** 工作集概要与整单金额由全部商户快照累加,零订单商户也保留。 */
public MchCheckoutBillSnapshotVo summary(MchCheckoutBillSnapshot snapshot) {
MchCheckoutBillSnapshotVo result = copy(snapshot, MchCheckoutBillSnapshotVo.class);
result.setOrderCount(snapshot.getOrders().size());
Set<Long> occupied = claimed(snapshot.getOrders().stream().map(SmHotelOrderCheckoutQueryVo::getOrderId).toList());
Set<Long> departments = snapshot.getMerchants().stream().map(MchCheckoutBillMerchantEntity::getDeptId).collect(Collectors.toSet());
result.setSelectableCount((int) snapshot.getOrders().stream().filter(row -> source.eligibility(row).isEmpty() && departments.contains(row.getDeptId()) && !occupied.contains(row.getOrderId())).count());
result.setMerchants(snapshot.getMerchants().stream().map(row -> copy(row, MchCheckoutBillMerchantVo.class)).toList());
long gross = 0, fee = 0, payable = 0;
for (MchCheckoutBillMerchantEntity row : snapshot.getMerchants()) {
gross = MchCheckoutBillMoney.add(gross, row.getGrossAmountFen());
fee = MchCheckoutBillMoney.add(fee, row.getFeeAmountFen());
payable = MchCheckoutBillMoney.add(payable, row.getPayableAmountFen());
}
if (gross != MchCheckoutBillMoney.add(fee, payable)) throw new IllegalStateException("结账预览汇总不一致");
result.setGrossAmountFen(gross);
result.setFeeAmountFen(fee);
result.setPayableAmountFen(payable);
return result;
}
/** 源事实映射成最小展示模型,金额非法时保留异常说明。 */
private MchCheckoutBillCandidateOrderVo orderVo(SmHotelOrderCheckoutQueryVo row, MchCheckoutBillMerchantEntity member, Set<Long> occupied, Long fee) {
MchCheckoutBillCandidateOrderVo result = copy(row, MchCheckoutBillCandidateOrderVo.class);
result.setSourceUpdatedAt(row.getUpdateTime());
result.setSourceLabel(row.getPartnerCode() != null ? row.getPartnerCode() : row.getDownstreamAppId() != null ? "开放应用 " + row.getDownstreamAppId() : row.getChannelNo() != null && row.getChannelNo().startsWith("APP") ? "App" : "后台/其他");
if (member != null) {
result.setMerchantId(member.getMerchantId());
result.setMerchantName(member.getMerchantName());
result.setDeptName(member.getDeptName());
}
String reason = source.eligibility(row);
if (member == null) reason = "订单当前归属超出关系或存在歧义";
boolean claimed = occupied.contains(row.getOrderId());
if (claimed) reason = "订单已经被有效结账单占用";
result.setClaimed(claimed);
result.setSelectable(reason.isEmpty());
result.setMessage(reason.isEmpty() ? "请人工核对履约完成及收款情况" : reason);
try {
long gross = MchCheckoutBillMoney.fen(row.getSellAmountYuan(), "订单金额");
result.setGrossAmountFen(gross);
if (fee != null) {
result.setFeeAmountFen(fee);
result.setPayableAmountFen(MchCheckoutBillMoney.payable(gross, fee));
}
} catch (IllegalArgumentException exception) {
result.setSelectable(false);
result.setMessage(exception.getMessage());
}
return result;
}
/** 将确定性完整集合切为当前页,限制页面尺寸并清理外部排序。 */
public static <T> Page<T> slice(Page<T> page, List<T> rows) {
page.setCurrent(Math.max(1, Math.min(page.getCurrent(), 1000000)));
page.setSize(Math.max(1, Math.min(page.getSize(), 100)));
page.orders().clear();
int from = (int) Math.min((page.getCurrent() - 1) * page.getSize(), rows.size());
page.setTotal(rows.size());
page.setRecords(rows.subList(from, Math.min(from + (int) page.getSize(), rows.size())));
return page;
}
/** 只复制独立展示模型中声明的属性,隐藏内部缓存和权限字段。 */
public static <T> T copy(Object value, Class<T> type) {
try {
T result = type.getDeclaredConstructor().newInstance();
BeanUtils.copyProperties(value, result);
return result;
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("结账展示模型转换失败", exception);
}
}
}
@@ -0,0 +1,165 @@
package com.bsd.pjcloud.wallet.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo;
import com.bsd.pjcloud.wallet.api.entity.mch.*;
import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutBillVo;
import com.bsd.pjcloud.wallet.mapper.mch.*;
import com.bsd.pjcloud.wallet.support.mch.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
/** 源事实复核及差异持久化;网络读取和钱包状态写入分为两个阶段。 */
@Slf4j
@Service
@RequiredArgsConstructor
public class MchCheckoutBillRecheckService {
/** 订单快照及定时复核游标。 */
private final MchCheckoutBillOrderMapper orders;
/** 商户当前付款状态。 */
private final MchCheckoutBillMerchantMapper merchants;
/** 账单主体统一锁序。 */
private final MchCheckoutBillMapper bills;
/** 授权及追加式审计。 */
private final MchCheckoutBillLedger ledger;
/** 酒店源数据只读适配。 */
private final MchCheckoutBillHotelSource source;
/** 当前用户角色数据范围。 */
private final MchCheckoutRelationAccess access;
/** 每一批复核结果独立提交,付款失败仍保留差异事实。 */
private final TransactionTemplate transaction;
/** 用户发起整单复核,只处理当前有效的商户明细。 */
public MchCheckoutBillVo recheck(Long id) {
MchCheckoutRelationScope scope = access.requireScope();
ledger.requireVisible(id, scope);
List<Long> active = merchants.selectList(Wrappers.<MchCheckoutBillMerchantEntity>lambdaQuery().eq(MchCheckoutBillMerchantEntity::getBillId, id).in(MchCheckoutBillMerchantEntity::getState, List.of("WAIT_PAY", "PAID")))
.stream().map(MchCheckoutBillMerchantEntity::getId).toList();
if (!active.isEmpty()) {
List<MchCheckoutBillOrderEntity> rows = orders.selectList(Wrappers.<MchCheckoutBillOrderEntity>lambdaQuery().eq(MchCheckoutBillOrderEntity::getBillId, id).in(MchCheckoutBillOrderEntity::getBillMerchantId, active).orderByAsc(MchCheckoutBillOrderEntity::getId));
check(rows, scope);
}
transaction.executeWithoutResult(status -> {
bills.lockBill(id);
ledger.requireVisible(id, scope);
ledger.event(id, null, null, "RECHECK", UUID.randomUUID().toString(), "人工发起整单源事实复核");
});
return ledger.requireVisible(id, scope);
}
/** 付款前强制复核完整单商户订单,错误也记录为可见复核结果。 */
public List<MchCheckoutBillOrderEntity> checkMerchant(Long id, Long memberRowId) {
MchCheckoutRelationScope scope = access.requireScope();
ledger.requireVisible(id, scope);
List<MchCheckoutBillOrderEntity> rows = orders.selectList(Wrappers.<MchCheckoutBillOrderEntity>lambdaQuery().eq(MchCheckoutBillOrderEntity::getBillId, id).eq(MchCheckoutBillOrderEntity::getBillMerchantId, memberRowId).orderByAsc(MchCheckoutBillOrderEntity::getId));
check(rows, scope);
return orders.selectList(Wrappers.<MchCheckoutBillOrderEntity>lambdaQuery().eq(MchCheckoutBillOrderEntity::getBillMerchantId, memberRowId));
}
/** 后台单租户读取最多500条到期记录,按到期时间和ID持续推进。 */
public void checkDue() {
if (TenantContextHolder.getTenantId() == null || TenantContextHolder.getTenantId() <= 0) throw new IllegalStateException("缺少有效的结账复核租户上下文");
List<MchCheckoutBillOrderEntity> due = orders.selectList(Wrappers.<MchCheckoutBillOrderEntity>lambdaQuery().eq(MchCheckoutBillOrderEntity::getSourceType, "HOTEL").isNotNull(MchCheckoutBillOrderEntity::getNextCheckAt)
.le(MchCheckoutBillOrderEntity::getNextCheckAt, LocalDateTime.now()).orderByAsc(MchCheckoutBillOrderEntity::getNextCheckAt).orderByAsc(MchCheckoutBillOrderEntity::getId).last("LIMIT 500"));
check(due, null);
}
/** 一批源数据先完整读取,再按账单主体锁串行更新,旧响应不能覆盖新复核。 */
private void check(List<MchCheckoutBillOrderEntity> rows, MchCheckoutRelationScope scope) {
if (rows.stream().anyMatch(row -> !"HOTEL".equals(row.getSourceType()))) throw new IllegalStateException("酒店复核接口收到不支持的源订单类型");
for (int from = 0; from < rows.size(); from += MchCheckoutBillHotelSource.BATCH_SIZE) {
List<MchCheckoutBillOrderEntity> batch = rows.subList(from, Math.min(from + MchCheckoutBillHotelSource.BATCH_SIZE, rows.size()));
LocalDateTime startedAt = LocalDateTime.now().truncatedTo(ChronoUnit.MICROS);
Map<Long, SmHotelOrderCheckoutQueryVo> facts;
try {
facts = source.fetch(batch.stream().map(MchCheckoutBillOrderEntity::getSourceOrderId).toList());
} catch (RuntimeException exception) {
log.warn("酒店结账源事实读取失败,订单笔数={}", batch.size(), exception);
facts = null;
}
Map<Long, SmHotelOrderCheckoutQueryVo> fetched = facts;
Map<Long, List<MchCheckoutBillOrderEntity>> byBill = new TreeMap<>();
for (MchCheckoutBillOrderEntity row : batch) byBill.computeIfAbsent(row.getBillId(), key -> new ArrayList<>()).add(row);
for (Map.Entry<Long, List<MchCheckoutBillOrderEntity>> entry : byBill.entrySet()) {
transaction.executeWithoutResult(status -> {
if (bills.lockBill(entry.getKey()) == null) throw new IllegalStateException("复核账单不存在");
if (scope != null) ledger.requireVisible(entry.getKey(), scope);
for (MchCheckoutBillOrderEntity original : entry.getValue()) {
MchCheckoutBillOrderEntity current = orders.lockOrder(entry.getKey(), original.getId());
MchCheckoutBillMerchantEntity member = merchants.selectById(current.getBillMerchantId());
if (!Set.of("WAIT_PAY", "PAID").contains(member.getState()) || (current.getLastCheckedAt() != null && current.getLastCheckedAt().isAfter(startedAt))) continue;
apply(current, member, fetched == null ? null : fetched.get(current.getSourceOrderId()), startedAt);
}
});
}
}
}
/** 更新复核元数据及必要事件,原金额和源快照不参与更新。 */
private void apply(MchCheckoutBillOrderEntity row, MchCheckoutBillMerchantEntity member, SmHotelOrderCheckoutQueryVo fact, LocalDateTime startedAt) {
String previousState = row.getLastCheckStatus();
String previousFingerprint = row.getLastObservedFingerprint();
String checkState;
String message;
if (fact == null) {
checkState = "QUERY_FAILED";
message = "酒店源数据查询失败,请重试;当前结果尚未完成复核";
row.setNextCheckAt(LocalDateTime.now().plusMinutes(5));
} else {
String fingerprint = source.fingerprint(fact);
row.setLastObservedFingerprint(fingerprint);
row.setLastVerifiedAt(LocalDateTime.now());
checkState = Objects.equals(row.getSourceFingerprint(), fingerprint) ? "NORMAL" : "DIFF";
message = "NORMAL".equals(checkState) ? "酒店源事实与原账一致" : difference(row, fact);
if ("DIFF".equals(checkState) && "PAID".equals(member.getState()) && Objects.equals(row.getHandledFingerprint(), fingerprint)) {
checkState = "HANDLED";
message = row.getLastCheckMessage();
}
row.setNextCheckAt("PAID".equals(member.getState()) ? LocalDateTime.now().plusHours(24) : LocalDateTime.now().plusMinutes(10));
}
row.setLastCheckStatus(checkState);
row.setLastCheckMessage(message);
row.setLastCheckedAt(startedAt);
if (orders.updateById(row) != 1) throw new IllegalStateException("订单复核结果更新失败");
if (!Objects.equals(previousState, checkState) || !Objects.equals(previousFingerprint, row.getLastObservedFingerprint())) {
String action = Set.of("DIFF", "QUERY_FAILED").contains(checkState) ? checkState : "RECHECK";
Map<String, Object> evidence = new LinkedHashMap<>();
evidence.put("previousState", previousState);
evidence.put("state", checkState);
evidence.put("message", message);
evidence.put("originalFingerprint", row.getSourceFingerprint());
evidence.put("observedFingerprint", row.getLastObservedFingerprint());
// 历史账单权限仅覆盖原归属,审计保留必要金额状态,避免暴露变更后其他部门的酒店资料。
Map<String, Object> observed = new LinkedHashMap<>();
if (fact != null) {
observed.put("orderId", fact.getOrderId());
observed.put("found", fact.isFound());
observed.put("sellAmountYuan", fact.getSellAmountYuan());
observed.put("orderStatus", fact.getOrderStatus());
observed.put("bookStatus", fact.getBookStatus());
observed.put("payStatus", fact.getPayStatus());
}
evidence.put("observedOrderFacts", observed);
ledger.event(row.getBillId(), member.getId(), row.getSourceOrderId(), action, UUID.randomUUID().toString(), evidence);
}
}
/** 将影响结账的源变化转换为明确原因,避免静默舍弃异常订单。 */
private String difference(MchCheckoutBillOrderEntity row, SmHotelOrderCheckoutQueryVo fact) {
if (!fact.isFound()) return "源酒店订单已删除或不存在,需核实原结账依据";
if (!"OK".equals(fact.getResolutionStatus())) return "源酒店归属缺失或无法唯一解析";
if (!Objects.equals(row.getDeptId(), fact.getDeptId()) || !Objects.equals(row.getHotelId(), fact.getHotelId())) return "酒店或归属部门已经变化";
try {
if (row.getGrossAmountFen() != MchCheckoutBillMoney.fen(fact.getSellAmountYuan(), "源订单金额")) return "订单售价已经变化,需重新核对金额";
} catch (IllegalArgumentException exception) {
return exception.getMessage();
}
return "订单状态、支付状态、来源或入住信息已经变化,请核实履约和退款情况";
}
}
@@ -0,0 +1,27 @@
package com.bsd.pjcloud.wallet.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutBillRequests;
import com.bsd.pjcloud.wallet.api.vo.mch.*;
/** 钱包结账账本公开业务契约,金额、权限、幂等和事务均由服务端终审。 */
public interface MchCheckoutBillService {
/** 按完整历史商户权限快照分页查询。 */
Page<MchCheckoutBillVo> page(Page<MchCheckoutBillVo> page, MchCheckoutBillRequests.ListQuery query);
/** 读取完整可见账单的原始与有效金额。 */
MchCheckoutBillVo detail(Long id);
/** 查询账单下全部商户权限快照与金额明细。 */
Page<MchCheckoutBillMerchantVo> merchants(Long id, Page<MchCheckoutBillMerchantVo> page);
/** 查询账单订单,按可选商户和异常状态筛选后分页。 */
Page<MchCheckoutBillOrderVo> orders(Long id, Long merchantId, boolean anomaliesOnly, Page<MchCheckoutBillOrderVo> page);
/** 查询账单追加式审计事件。 */
Page<MchCheckoutBillEventVo> events(Long id, Page<MchCheckoutBillEventVo> page);
/** 按预览创建完整待付款账单及唯一订单占用。 */
MchCheckoutBillVo create(MchCheckoutBillRequests.Create request);
/** 复核源事实后登记单个商户全额付款或零元结清。 */
MchCheckoutBillVo pay(Long id, Long merchantId, MchCheckoutBillRequests.Payment request);
/** 按原版本作废待付款商户明细并释放它的订单。 */
MchCheckoutBillVo voidMerchant(Long id, Long merchantId, MchCheckoutBillRequests.VoidMerchant request);
/** 登记已付款差异的财务核实与处理依据。 */
MchCheckoutBillVo handle(Long id, Long orderRowId, MchCheckoutBillRequests.Handle request);
}
@@ -0,0 +1,29 @@
package com.bsd.pjcloud.wallet.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.wallet.api.dto.mch.MchSceneDto;
import com.bsd.pjcloud.wallet.api.entity.mch.MchSceneEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchSceneVo;
/**
* 商户场景服务,负责场景协议、商户数据范围和唯一配置的保存。
*/
public interface MchSceneService extends IService<MchSceneEntity> {
/**
* 查询当前管理用户可访问商户的场景配置,读取操作不创建记录。
*
* @param mchId 商户 ID
* @param sceneCode 场景持久化编码
* @return 匹配配置,未配置时返回空
*/
MchSceneVo getForManagement(Long mchId, String sceneCode);
/**
* 按商户和场景新增或更新唯一配置,并发首次保存时复用已创建记录。
*
* @param dto 管理端场景开关及固定抽成金额,金额缺省时保留已有值
* @return 本次保存后的场景配置
*/
MchSceneVo saveOrUpdateConfig(MchSceneDto dto);
}
@@ -148,6 +148,14 @@ public interface WalletManageService extends IService<WalletManageEntity> {
List<WalletManageVo> scopeList(WalletManageDto dto, DataScope dataScope);
/**
* 查询月末钱包记录,余额排序覆盖当前筛选范围内的全部钱包后再分页。
*
* @param page 分页及排序参数;balance 支持升序、降序,同额按钱包主键稳定排序
* @param dto 月份及用户、部门、身份、钱包类型筛选条件
* @param dataScope 当前操作者的数据权限范围
* @return 仅包含当页记录且已装配用户资料的月末余额分页
*/
Page<WalletManageVo> scopeQbPage(Page page, WalletManageBalanceDto dto, DataScope dataScope);
/**
@@ -11,6 +11,7 @@ import cn.hutool.core.util.ObjectUtil;
import cn.hutool.extra.spring.SpringUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
@@ -30,6 +31,7 @@ import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.core.util.CollectionUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum;
import com.bsd.pjcloud.common.data.datascope.PjcloudDefaultDataScopeHandle;
import com.bsd.pjcloud.common.data.resolver.ParamResolver;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
@@ -64,7 +66,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.DateTimeException;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.util.*;
import java.util.stream.Collectors;
@@ -693,11 +697,92 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
return result;
}
/**
* 查询月末钱包记录;余额排序在数据库分页前完成,其余请求沿用原有分页后余额回填。
*
* @param page 分页与排序参数;仅 balance 排序进入全量月末余额查询
* @param dto 月份及用户、部门、身份、钱包类型筛选条件
* @param dataScope 当前操作者的数据权限范围
* @return 已按指定顺序分页并装配用户资料的月末钱包记录
*/
@Override
public Page<WalletManageVo> scopeQbPage(Page page, WalletManageBalanceDto dto, DataScope dataScope) {
List<OrderItem> orders = page.orders();
Optional<OrderItem> balanceOrder = orders.stream().filter(order -> "balance".equals(order.getColumn())).findFirst();
if (balanceOrder.isPresent()) {
return queryWalletRecordBalancePage(page, dto, dataScope, balanceOrder.get().isAsc());
}
return queryWalletRecordPage(page, dto, dataScope, true);
}
/**
* 对全部匹配钱包的月末余额排序后分页,仅回填当页用户资料,不把全量记录载入应用内存。
*
* @param page 分页参数;复用全局每页最多 1000 条限制,临时关闭自动计数后恢复原标记
* @param dto 月度钱包记录筛选条件
* @param dataScope 当前操作者的数据权限范围;计数使用独立副本,避免权限计算污染列表范围
* @param ascending 是否按余额升序;同额时按钱包主键倒序保证分页稳定
* @return 数据库全量排序后的当页钱包记录
*/
private Page<WalletManageVo> queryWalletRecordBalancePage(Page<WalletManageVo> page, WalletManageBalanceDto dto, DataScope dataScope, boolean ascending) {
if (page.getCurrent() < 1 || page.getSize() <= 0) {
throw new IllegalArgumentException("余额排序必须使用有效页码和正数分页大小");
}
LocalDateTime monthEndExclusive = walletRecordMonthEnd(dto.getWalletRecordMonth());
WalletManageDto walletDto = BeanUtils.toBean(dto, WalletManageDto.class);
prepareIdentityQuery(walletDto);
// 只接受服务端固定列名,避免将任意请求排序字段带入余额派生表。
page.setOrders(new ArrayList<>(List.of(ascending ? OrderItem.asc("balance") : OrderItem.desc("balance"), OrderItem.desc("id"))));
boolean searchCount = page.searchCount();
DataScope queryScope = dataScope == null ? DataScope.of() : dataScope;
if (searchCount) {
// 权限计算可能追加部门范围,计数必须使用独立副本,不能影响随后列表的全部数据权限。
DataScope countScope = new DataScope();
countScope.setScopeDeptName(queryScope.getScopeDeptName());
countScope.setScopeUserName(queryScope.getScopeUserName());
countScope.setDeptList(new ArrayList<>(queryScope.getDeptList()));
countScope.setUsername(queryScope.getUsername());
countScope.setIsOnly(queryScope.getIsOnly());
countScope.setFunc(DataScopeFuncEnum.COUNT);
// 数量只依赖钱包筛选,避免对月末流水窗口重复扫描和计数。
page.setTotal(baseMapper.count(walletDto, countScope));
if (page.getTotal() == 0) {
page.setRecords(Collections.emptyList());
return page;
}
}
try {
page.setSearchCount(false);
Page<WalletManageVo> result = baseMapper.scopeQbBalancePage(page, walletDto, monthEndExclusive, queryScope);
disposeList(result.getRecords());
return result;
} finally {
page.setSearchCount(searchCount);
}
}
/**
* 将合法统计月份转换为下月首日的排他上界,避免在流水时间列上使用日期格式化函数。
*
* @param month 页面传入的 YYYY-MM 月份;缺失、格式错误或超出 MySQL 日期范围时拒绝查询
* @return 下月首日零点,作为绑定参数限制需要参与月末余额计算的流水
*/
private LocalDateTime walletRecordMonthEnd(String month) {
if (month == null || !month.matches("\\d{4}-\\d{2}")) {
throw new IllegalArgumentException("请选择有效月份(YYYY-MM)");
}
try {
YearMonth recordMonth = YearMonth.parse(month);
YearMonth nextMonth = recordMonth.plusMonths(1);
if (recordMonth.getYear() < 1000 || nextMonth.getYear() > 9999) {
throw new IllegalArgumentException("统计月份超出支持范围");
}
return nextMonth.atDay(1).atStartOfDay();
} catch (DateTimeException exception) {
throw new IllegalArgumentException("请选择有效月份(YYYY-MM)", exception);
}
}
/**
* 分页计算月末钱包余额导出数据,并把完整用户资料留给统一导出服务装配。
*
@@ -722,6 +807,7 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
*/
private Page<WalletManageVo> queryWalletRecordPage(Page page, WalletManageBalanceDto dto,
DataScope dataScope, boolean fillUserInfo) {
LocalDateTime monthEndExclusive = walletRecordMonthEnd(dto.getWalletRecordMonth());
WalletManageDto walletDto = BeanUtils.toBean(dto, WalletManageDto.class);
prepareIdentityQuery(walletDto);
Page<WalletManageVo> walletPage = baseMapper.scopePage(page, walletDto, dataScope);
@@ -743,10 +829,11 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
if (ObjectUtil.isEmpty(walletBalanceDto.getUserId())) {
walletBalanceDto.setUserIds(userIds);
}
List<WalletManageBalanceVo> itemList = baseMapper.selectBalance(walletBalanceDto);
List<WalletManageBalanceVo> itemList = baseMapper.selectBalance(walletBalanceDto, monthEndExclusive);
if (ObjectUtil.isEmpty(itemList)) continue;
for (WalletManageBalanceVo j : itemList) {
walletPage.getRecords().stream().filter(v -> ObjectUtil.equals(i.getId(), v.getWalletTypeId()) && ObjectUtil.equals(v.getUserId(), j.getUserId())).findFirst().ifPresent(v -> {
// 同用户、同类型可能存在历史重复钱包,全部使用同一月末余额,保持切换排序前后一致。
walletPage.getRecords().stream().filter(v -> ObjectUtil.equals(i.getId(), v.getWalletTypeId()) && ObjectUtil.equals(v.getUserId(), j.getUserId())).forEach(v -> {
if (ObjectUtil.isNotEmpty(j.getBalance())) {
v.setBalance(j.getBalance());
v.setLastTopUpTime(j.getCreateTime());
@@ -825,8 +912,16 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
return list;
}
/**
* 查询当前筛选及权限范围内的钱包月末余额,复用分页查询的最新有效流水口径。
*
* @param dto 必须包含合法统计月份的钱包筛选条件,身份及钱包类型条件会在原有流程中回填
* @param dataScope 当前操作者的数据权限范围
* @return 已装配用户资料的全部匹配钱包,未发生有效流水的钱包余额为零
*/
@Override
public List<WalletManageVo> scopeQbList(WalletManageBalanceDto dto, DataScope dataScope) {
LocalDateTime monthEndExclusive = walletRecordMonthEnd(dto.getWalletRecordMonth());
WalletManageDto walletDto = BeanUtils.toBean(dto, WalletManageDto.class);
prepareIdentityQuery(walletDto);
BeanUtil.copyProperties(walletDto, dto);
@@ -847,10 +942,11 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
if (ObjectUtil.isEmpty(dto.getUserId())) {
dto.setUserIds(userIds);
}
List<WalletManageBalanceVo> itemList = baseMapper.selectBalance(dto);
List<WalletManageBalanceVo> itemList = baseMapper.selectBalance(dto, monthEndExclusive);
if (ObjectUtil.isEmpty(itemList)) continue;
for (WalletManageBalanceVo j : itemList) {
walletList.stream().filter(v -> ObjectUtil.equals(i.getId(), v.getWalletTypeId()) && ObjectUtil.equals(v.getUserId(), j.getUserId())).findFirst().ifPresent(v -> {
// 与分页、余额排序共用用户和钱包类型口径,不能只回填重复钱包中的第一条。
walletList.stream().filter(v -> ObjectUtil.equals(i.getId(), v.getWalletTypeId()) && ObjectUtil.equals(v.getUserId(), j.getUserId())).forEach(v -> {
if (ObjectUtil.isNotEmpty(j.getBalance())) {
v.setBalance(j.getBalance());
v.setLastTopUpTime(j.getCreateTime());
@@ -0,0 +1,435 @@
package com.bsd.pjcloud.wallet.service.impl.mch;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo;
import com.bsd.pjcloud.wallet.api.constant.mch.MchCheckoutBillStates;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutBillRequests;
import com.bsd.pjcloud.wallet.api.entity.mch.*;
import com.bsd.pjcloud.wallet.api.vo.mch.*;
import com.bsd.pjcloud.wallet.mapper.mch.*;
import com.bsd.pjcloud.wallet.service.MchCheckoutBillPreviewService;
import com.bsd.pjcloud.wallet.service.MchCheckoutBillRecheckService;
import com.bsd.pjcloud.wallet.service.MchCheckoutBillService;
import com.bsd.pjcloud.wallet.support.mch.*;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/** 酒店结账账本事务,远程事实读取在本地写事务之外执行。 */
@Service
@RequiredArgsConstructor
public class MchCheckoutBillServiceImpl implements MchCheckoutBillService {
/** 账单主体访问。 */
private final MchCheckoutBillMapper billMapper;
/** 商户权限快照及付款明细访问。 */
private final MchCheckoutBillMerchantMapper merchantMapper;
/** 原订单金额快照访问。 */
private final MchCheckoutBillOrderMapper orderMapper;
/** 当前订单唯一占用访问。 */
private final MchCheckoutOrderClaimMapper claimMapper;
/** 追加式审计查询。 */
private final MchCheckoutBillEventMapper eventMapper;
/** 完整候选与预览快照。 */
private final MchCheckoutBillPreviewService previews;
/** 酒店只读事实读取与摘要。 */
private final MchCheckoutBillHotelSource source;
/** 关系版本和当前商户行锁。 */
private final MchCheckoutBillContext context;
/** 统一角色数据范围。 */
private final MchCheckoutRelationAccess access;
/** 财务账本授权及审计。 */
private final MchCheckoutBillLedger ledger;
/** 独立源事实复核及差异持久化。 */
private final MchCheckoutBillRecheckService rechecks;
/** Spring本地事务,覆盖主体、明细、占用和事件。 */
private final TransactionTemplate transaction;
/** 整组历史权限在数据库分页之前计算。 */
@Override
public Page<MchCheckoutBillVo> page(Page<MchCheckoutBillVo> page, MchCheckoutBillRequests.ListQuery query) {
safe(page);
return billMapper.pageVisible(page, query, access.requireScope());
}
/** 无权及不存在使用统一提示。 */
@Override
public MchCheckoutBillVo detail(Long id) {
return ledger.requireVisible(id, access.requireScope());
}
/** 查询所有成员快照,零订单成员持续参与权限判断。 */
@Override
public Page<MchCheckoutBillMerchantVo> merchants(Long id, Page<MchCheckoutBillMerchantVo> page) {
detail(id);
Page<MchCheckoutBillMerchantEntity> data = merchantMapper.selectPage(new Page<>(safe(page).getCurrent(), page.getSize()),
Wrappers.<MchCheckoutBillMerchantEntity>lambdaQuery().eq(MchCheckoutBillMerchantEntity::getBillId, id).orderByDesc(MchCheckoutBillMerchantEntity::getOrderCount).orderByAsc(MchCheckoutBillMerchantEntity::getMerchantId));
return viewPage(data, MchCheckoutBillMerchantVo.class);
}
/** 按商户及异常筛选在数据库分页之前处理。 */
@Override
public Page<MchCheckoutBillOrderVo> orders(Long id, Long merchantId, boolean anomaliesOnly, Page<MchCheckoutBillOrderVo> page) {
detail(id);
List<MchCheckoutBillMerchantEntity> members = merchantMapper.selectList(Wrappers.<MchCheckoutBillMerchantEntity>lambdaQuery().eq(MchCheckoutBillMerchantEntity::getBillId, id));
List<Long> activeIds = members.stream().filter(row -> !"VOID".equals(row.getState())).map(MchCheckoutBillMerchantEntity::getId).toList();
if (anomaliesOnly && activeIds.isEmpty()) return new Page<>(safe(page).getCurrent(), page.getSize(), 0);
Page<MchCheckoutBillOrderEntity> data = orderMapper.selectPage(new Page<>(safe(page).getCurrent(), page.getSize()),
Wrappers.<MchCheckoutBillOrderEntity>lambdaQuery().eq(MchCheckoutBillOrderEntity::getBillId, id)
.eq(merchantId != null, MchCheckoutBillOrderEntity::getMerchantId, merchantId)
.in(anomaliesOnly, MchCheckoutBillOrderEntity::getBillMerchantId, activeIds)
.in(anomaliesOnly, MchCheckoutBillOrderEntity::getLastCheckStatus, List.of("DIFF", "QUERY_FAILED"))
.orderByAsc(MchCheckoutBillOrderEntity::getId));
Map<Long, String> states = members.stream().collect(Collectors.toMap(MchCheckoutBillMerchantEntity::getId, MchCheckoutBillMerchantEntity::getState));
Page<MchCheckoutBillOrderVo> result = viewPage(data, MchCheckoutBillOrderVo.class);
result.getRecords().forEach(row -> row.setMerchantState(states.get(row.getBillMerchantId())));
return result;
}
/** 按事件主键倒序读取完整业务轨迹。 */
@Override
public Page<MchCheckoutBillEventVo> events(Long id, Page<MchCheckoutBillEventVo> page) {
detail(id);
Page<MchCheckoutBillEventEntity> data = eventMapper.selectPage(new Page<>(safe(page).getCurrent(), page.getSize()),
Wrappers.<MchCheckoutBillEventEntity>lambdaQuery().eq(MchCheckoutBillEventEntity::getBillId, id).orderByDesc(MchCheckoutBillEventEntity::getId));
return viewPage(data, MchCheckoutBillEventVo.class);
}
/** 幂等命中在临时令牌校验之前处理,缓存过期仍可恢复已提交结果。 */
@Override
public MchCheckoutBillVo create(MchCheckoutBillRequests.Create request) {
MchCheckoutRelationScope scope = access.requireScope();
MchCheckoutBillEntity existing = findCreated(request.getRequestId());
if (existing != null) return replayCreate(existing, request, scope);
MchCheckoutBillSnapshot snapshot = previews.require(request.getPreviewToken(), true);
if (!Objects.equals(snapshot.getRequestHash(), previews.contentHash(snapshot))) throw new IllegalStateException("结账预览内容异常,请重新预览");
assertFresh(snapshot.getOrders(), source.fetch(snapshot.getOrders().stream().map(SmHotelOrderCheckoutQueryVo::getOrderId).toList()));
snapshot.setSourceVerifiedAt(LocalDateTime.now());
MchCheckoutRelationScope freshScope = access.requireScope();
try {
Long id = transaction.execute(status -> createLocked(snapshot, request, freshScope));
return detail(Objects.requireNonNull(id));
} catch (DuplicateKeyException exception) {
// 唯一键冲突后的查询在已回滚事务之外进行,响应超时重试能恢复唯一成功结果。
MchCheckoutBillEntity committed = findCreated(request.getRequestId());
if (committed != null) return replayCreate(committed, request, freshScope);
throw new IllegalStateException("所选订单已被其他有效结账单占用,请重新查询", exception);
}
}
/** 付款前保存独立复核结果,再在主体锁内终审当前明细版本和源摘要。 */
@Override
public MchCheckoutBillVo pay(Long id, Long merchantId, MchCheckoutBillRequests.Payment request) {
detail(id);
MchCheckoutBillMerchantEntity original = requireMerchant(id, merchantId);
String digest = source.hash(Arrays.asList(id, merchantId, request.getVersion(), request.getPaymentTime(), trim(request.getPaymentNo()), normalizedPics(request.getPaymentPics()), trim(request.getRemark())));
if (Objects.equals(original.getPaymentRequestId(), request.getRequestId())) {
assertDigest(original.getPaymentRequestHash(), digest);
return detail(id);
}
assertWaiting(original, request.getVersion());
if (original.getPayableAmountFen() > 0 && trim(request.getPaymentNo()).isEmpty() && normalizedPics(request.getPaymentPics()).isEmpty()) throw new IllegalArgumentException("请提供付款流水号或至少一张凭证");
if (request.getPaymentTime().isAfter(LocalDateTime.now())) throw new IllegalArgumentException("付款时间不能晚于当前时间");
List<MchCheckoutBillOrderEntity> checked = rechecks.checkMerchant(id, original.getId());
MchCheckoutRelationScope scope = access.requireScope();
transaction.executeWithoutResult(status -> {
MchCheckoutBillEntity bill = requireLocked(id, scope);
MchCheckoutBillMerchantEntity row = merchantMapper.lockMerchant(id, merchantId);
if (row == null) throw new IllegalStateException("商户结账明细不存在");
if (Objects.equals(row.getPaymentRequestId(), request.getRequestId())) {
assertDigest(row.getPaymentRequestHash(), digest);
return;
}
assertWaiting(row, request.getVersion());
List<MchCheckoutBillOrderEntity> current = readOrders(row.getId());
if (current.size() != checked.size() || current.size() != row.getOrderCount()) throw new IllegalStateException("订单明细数量异常,请先核对账单");
if (current.stream().anyMatch(order -> "QUERY_FAILED".equals(order.getLastCheckStatus()))) throw new IllegalStateException("酒店源数据复核查询失败,请稍后重试登记付款");
if (current.stream().anyMatch(order -> !"NORMAL".equals(order.getLastCheckStatus()))) throw new IllegalStateException("订单源事实已变化,请核对差异后作废待付款明细重新结账");
// 主体行锁使复核与付款/作废串行,已确认差异也不能覆盖付款校验。
row.setState(MchCheckoutBillStates.Merchant.PAID.name());
row.setVersion(Math.addExact(row.getVersion(), 1));
row.setPaymentTime(request.getPaymentTime());
row.setPaidAmountFen(row.getPayableAmountFen());
row.setPaymentNo(trim(request.getPaymentNo()));
row.setPaymentPics(ledger.encode(normalizedPics(request.getPaymentPics())));
row.setPaymentRemark(trim(request.getRemark()));
row.setPaymentRequestId(request.getRequestId());
row.setPaymentRequestHash(digest);
requireWrite(merchantMapper.updateById(row));
orderMapper.update(null, Wrappers.<MchCheckoutBillOrderEntity>lambdaUpdate().eq(MchCheckoutBillOrderEntity::getBillMerchantId, row.getId()).set(MchCheckoutBillOrderEntity::getNextCheckAt, LocalDateTime.now().plusHours(24)));
ledger.refreshState(bill);
ledger.event(id, row.getId(), null, MchCheckoutBillStates.Action.PAY.name(), request.getRequestId(), Map.of("request", request, "paidAmountFen", row.getPaidAmountFen(), "result", "PAID"));
});
return detail(id);
}
/** 作废只释放指定待付款商户的占用,已付款或旧版本不能进入。 */
@Override
public MchCheckoutBillVo voidMerchant(Long id, Long merchantId, MchCheckoutBillRequests.VoidMerchant request) {
MchCheckoutRelationScope scope = access.requireScope();
String digest = source.hash(Arrays.asList(id, merchantId, request.getVersion(), trim(request.getReason())));
transaction.executeWithoutResult(status -> {
MchCheckoutBillEntity bill = requireLocked(id, scope);
MchCheckoutBillMerchantEntity row = merchantMapper.lockMerchant(id, merchantId);
if (row == null) throw new IllegalStateException("商户结账明细不存在");
if (Objects.equals(row.getVoidRequestId(), request.getRequestId())) {
assertDigest(row.getVoidRequestHash(), digest);
return;
}
assertWaiting(row, request.getVersion());
row.setState(MchCheckoutBillStates.Merchant.VOID.name());
row.setVersion(Math.addExact(row.getVersion(), 1));
row.setVoidReason(trim(request.getReason()));
row.setVoidRequestId(request.getRequestId());
row.setVoidRequestHash(digest);
requireWrite(merchantMapper.updateById(row));
int released = claimMapper.delete(Wrappers.<MchCheckoutOrderClaimEntity>lambdaQuery().eq(MchCheckoutOrderClaimEntity::getBillId, id).eq(MchCheckoutOrderClaimEntity::getBillMerchantId, row.getId()));
if (released != row.getOrderCount()) throw new IllegalStateException("订单占用数量异常,作废已回滚");
orderMapper.update(null, Wrappers.<MchCheckoutBillOrderEntity>lambdaUpdate().eq(MchCheckoutBillOrderEntity::getBillMerchantId, row.getId()).set(MchCheckoutBillOrderEntity::getNextCheckAt, null));
ledger.refreshState(bill);
ledger.event(id, row.getId(), null, MchCheckoutBillStates.Action.VOID.name(), request.getRequestId(), Map.of("request", request, "releasedOrders", released));
});
return detail(id);
}
/** 已付款差异按观察摘要登记处理,事件幂等且保留原始金额和源快照。 */
@Override
public MchCheckoutBillVo handle(Long id, Long orderRowId, MchCheckoutBillRequests.Handle request) {
MchCheckoutRelationScope scope = access.requireScope();
String digest = source.hash(Arrays.asList(id, orderRowId, request.getObservedFingerprint(), trim(request.getRemark()), normalizedPics(request.getEvidencePics())));
Map<String, Object> evidence = Map.of("requestHash", digest, "request", request);
transaction.executeWithoutResult(status -> {
requireLocked(id, scope);
MchCheckoutBillEventEntity previous = eventMapper.selectOne(Wrappers.<MchCheckoutBillEventEntity>lambdaQuery().eq(MchCheckoutBillEventEntity::getAction, "HANDLE").eq(MchCheckoutBillEventEntity::getRequestId, request.getRequestId()));
if (previous != null) {
assertDigest(previous.getRequestHash(), source.hash(evidence));
return;
}
MchCheckoutBillOrderEntity order = orderMapper.lockOrder(id, orderRowId);
if (order == null) throw new IllegalStateException("结账订单不存在");
MchCheckoutBillMerchantEntity member = merchantMapper.selectById(order.getBillMerchantId());
if (!"PAID".equals(member.getState()) || !"DIFF".equals(order.getLastCheckStatus()) || !Objects.equals(order.getLastObservedFingerprint(), request.getObservedFingerprint())) throw new IllegalStateException("仅可处理当前已付款差异,差异已变化时请重新核对");
order.setHandledFingerprint(request.getObservedFingerprint());
order.setLastCheckStatus(MchCheckoutBillStates.Check.HANDLED.name());
order.setLastCheckMessage(trim(request.getRemark()));
requireWrite(orderMapper.updateById(order));
ledger.event(id, member.getId(), order.getSourceOrderId(), MchCheckoutBillStates.Action.HANDLE.name(), request.getRequestId(), evidence);
});
return detail(id);
}
/** 事务内重算每一笔金额后批量写入,缓存中的汇总不能覆盖实际计费。 */
private Long createLocked(MchCheckoutBillSnapshot snapshot, MchCheckoutBillRequests.Create request, MchCheckoutRelationScope scope) {
context.lockAndValidate(snapshot, scope);
LocalDateTime now = LocalDateTime.now();
String actor = SecurityUtils.getUser().getUsername();
Long billId = IdWorker.getId();
Map<Long, MchCheckoutBillMerchantEntity> membersByDept = new LinkedHashMap<>();
for (MchCheckoutBillMerchantEntity original : snapshot.getMerchants()) {
MchCheckoutBillMerchantEntity member = MchCheckoutBillPreviewService.copy(original, MchCheckoutBillMerchantEntity.class);
member.setId(IdWorker.getId());
member.setBillId(billId);
member.setOrderCount(0);
member.setGrossAmountFen(0L);
member.setFeeAmountFen(0L);
member.setPayableAmountFen(0L);
member.setState("NO_ORDERS");
member.setCreateBy(actor);
member.setUpdateBy(actor);
member.setCreateTime(now);
member.setUpdateTime(now);
membersByDept.put(member.getDeptId(), member);
}
List<MchCheckoutBillOrderEntity> orders = new ArrayList<>();
List<MchCheckoutOrderClaimEntity> claims = new ArrayList<>();
long grossTotal = 0, feeTotal = 0, payableTotal = 0;
for (SmHotelOrderCheckoutQueryVo fact : snapshot.getOrders()) {
MchCheckoutBillMerchantEntity member = membersByDept.get(fact.getDeptId());
if (member == null || !source.eligibility(fact).isEmpty()) throw new IllegalStateException("订单归属或状态不允许结账");
long gross = MchCheckoutBillMoney.fen(fact.getSellAmountYuan(), "订单金额");
long fee = snapshot.getFeePerOrderFen();
long payable = MchCheckoutBillMoney.payable(gross, fee);
member.setOrderCount(member.getOrderCount() + 1);
member.setGrossAmountFen(MchCheckoutBillMoney.add(member.getGrossAmountFen(), gross));
member.setFeeAmountFen(MchCheckoutBillMoney.add(member.getFeeAmountFen(), fee));
member.setPayableAmountFen(MchCheckoutBillMoney.add(member.getPayableAmountFen(), payable));
member.setState("WAIT_PAY");
grossTotal = MchCheckoutBillMoney.add(grossTotal, gross);
feeTotal = MchCheckoutBillMoney.add(feeTotal, fee);
payableTotal = MchCheckoutBillMoney.add(payableTotal, payable);
MchCheckoutBillOrderEntity order = new MchCheckoutBillOrderEntity();
order.setId(IdWorker.getId());
order.setBillId(billId);
order.setBillMerchantId(member.getId());
order.setMerchantId(member.getMerchantId());
order.setSourceType("HOTEL");
order.setSourceOrderId(fact.getOrderId());
order.setHotelId(fact.getHotelId());
order.setHotelName(fact.getHotelName());
order.setDeptId(fact.getDeptId());
order.setChannelNo(fact.getChannelNo());
order.setPlatformNo(fact.getPlatformNo());
order.setCheckInDate(fact.getCheckInDate());
order.setCheckOutDate(fact.getCheckOutDate());
order.setRooms(fact.getRooms());
order.setGrossAmountFen(gross);
order.setFeeAmountFen(fee);
order.setPayableAmountFen(payable);
order.setOrderStatus(fact.getOrderStatus());
order.setBookStatus(fact.getBookStatus());
order.setPayStatus(fact.getPayStatus());
order.setSourceUpdatedAt(fact.getUpdateTime());
order.setSourceFingerprint(source.fingerprint(fact));
order.setSourceSnapshot(ledger.encode(fact));
order.setConfirmedUserId(SecurityUtils.getUser().getId());
order.setConfirmedBy(actor);
order.setConfirmedAt(now);
order.setLastCheckStatus("NORMAL");
order.setLastCheckMessage("创建前源数据核对一致");
order.setLastObservedFingerprint(order.getSourceFingerprint());
order.setLastCheckedAt(snapshot.getSourceVerifiedAt());
order.setLastVerifiedAt(snapshot.getSourceVerifiedAt());
order.setNextCheckAt(now.plusMinutes(10));
order.setCreateBy(actor);
order.setUpdateBy(actor);
order.setCreateTime(now);
order.setUpdateTime(now);
orders.add(order);
MchCheckoutOrderClaimEntity claim = new MchCheckoutOrderClaimEntity();
claim.setId(IdWorker.getId());
claim.setSourceType("HOTEL");
claim.setSourceOrderId(fact.getOrderId());
claim.setBillId(billId);
claim.setBillMerchantId(member.getId());
claim.setCreateBy(actor);
claim.setCreateTime(now);
claims.add(claim);
}
if (orders.isEmpty() || grossTotal != MchCheckoutBillMoney.add(feeTotal, payableTotal)) throw new IllegalStateException("结账订单或金额汇总异常");
MchCheckoutBillEntity bill = new MchCheckoutBillEntity();
bill.setId(billId);
bill.setBillNo("HCO" + billId);
bill.setSourceType("HOTEL");
bill.setRelationId(snapshot.getRelationId());
bill.setRelationVersion(snapshot.getRelationVersion());
bill.setIntermediaryName(snapshot.getIntermediaryName());
bill.setPeriodStart(snapshot.getPeriodStart());
bill.setPeriodEnd(snapshot.getPeriodEnd());
bill.setRuleVersion(1);
bill.setFeePerOrderFen(snapshot.getFeePerOrderFen());
bill.setOrderCount(orders.size());
bill.setMerchantCount(membersByDept.size());
bill.setGrossAmountFen(grossTotal);
bill.setFeeAmountFen(feeTotal);
bill.setPayableAmountFen(payableTotal);
bill.setState("WAIT_PAY");
bill.setVersion(0);
bill.setCreateRequestId(request.getRequestId());
bill.setRequestHash(snapshot.getRequestHash());
bill.setPreviewToken(request.getPreviewToken());
bill.setSourceVerifiedAt(snapshot.getSourceVerifiedAt());
bill.setRemark(snapshot.getRemark());
requireWrite(billMapper.insert(bill));
List<MchCheckoutBillMerchantEntity> members = new ArrayList<>(membersByDept.values());
for (int start = 0; start < members.size(); start += 500) {
List<MchCheckoutBillMerchantEntity> batch = members.subList(start, Math.min(start + 500, members.size()));
if (merchantMapper.insertRows(batch) != batch.size()) throw new IllegalStateException("商户结账快照写入不完整");
}
for (int start = 0; start < orders.size(); start += 500) {
List<MchCheckoutBillOrderEntity> batch = orders.subList(start, Math.min(start + 500, orders.size()));
if (orderMapper.insertRows(batch) != batch.size()) throw new IllegalStateException("结账订单明细写入不完整");
List<MchCheckoutOrderClaimEntity> claimBatch = claims.subList(start, Math.min(start + 500, claims.size()));
if (claimMapper.insertRows(claimBatch) != claimBatch.size()) throw new IllegalStateException("结账订单占用写入不完整");
}
ledger.event(billId, null, null, MchCheckoutBillStates.Action.CREATE.name(), request.getRequestId(), Map.of("requestHash", snapshot.getRequestHash(), "orderCount", orders.size(), "grossAmountFen", grossTotal, "feeAmountFen", feeTotal, "payableAmountFen", payableTotal));
return billId;
}
/** 所选订单按关键事实逐项重新检查,变化必须重新预览。 */
private void assertFresh(List<SmHotelOrderCheckoutQueryVo> original, Map<Long, SmHotelOrderCheckoutQueryVo> fresh) {
for (SmHotelOrderCheckoutQueryVo row : original) {
SmHotelOrderCheckoutQueryVo current = fresh.get(row.getOrderId());
if (current == null || !source.eligibility(current).isEmpty() || !source.fingerprint(row).equals(source.fingerprint(current))) throw new IllegalStateException("订单" + row.getOrderId() + "的金额、归属或状态已变化,请重新预览");
}
}
/** 先锁主体再校验整组历史权限,后续状态操作共用这一锁序。 */
private MchCheckoutBillEntity requireLocked(Long id, MchCheckoutRelationScope scope) {
MchCheckoutBillEntity row = billMapper.lockBill(id);
if (row == null) throw new IllegalStateException("结账单不存在或没有完整数据权限");
ledger.requireVisible(id, scope);
return row;
}
/** 按原商户主键读取单份账单明细。 */
private MchCheckoutBillMerchantEntity requireMerchant(Long id, Long merchantId) {
MchCheckoutBillMerchantEntity row = merchantMapper.selectOne(Wrappers.<MchCheckoutBillMerchantEntity>lambdaQuery().eq(MchCheckoutBillMerchantEntity::getBillId, id).eq(MchCheckoutBillMerchantEntity::getMerchantId, merchantId));
if (row == null) throw new IllegalStateException("商户结账明细不存在");
return row;
}
/** 读取同一商户结账明细完整订单,数量受单账单10000笔限制。 */
private List<MchCheckoutBillOrderEntity> readOrders(Long memberRowId) {
return orderMapper.selectList(Wrappers.<MchCheckoutBillOrderEntity>lambdaQuery().eq(MchCheckoutBillOrderEntity::getBillMerchantId, memberRowId).orderByAsc(MchCheckoutBillOrderEntity::getId));
}
/** 幂等记录按租户自动隔离读取。 */
private MchCheckoutBillEntity findCreated(String requestId) {
return billMapper.selectOne(Wrappers.<MchCheckoutBillEntity>lambdaQuery().eq(MchCheckoutBillEntity::getCreateRequestId, requestId));
}
/** 已提交重试校验请求内容,原预览令牌失效后仍可返回同一结果。 */
private MchCheckoutBillVo replayCreate(MchCheckoutBillEntity existing, MchCheckoutBillRequests.Create request, MchCheckoutRelationScope scope) {
if (!Objects.equals(existing.getPreviewToken(), request.getPreviewToken())) {
MchCheckoutBillSnapshot snapshot = previews.require(request.getPreviewToken(), true);
assertDigest(existing.getRequestHash(), snapshot.getRequestHash());
}
return ledger.requireVisible(existing.getId(), scope);
}
/** 付款及作废只接受原版本的待付款明细。 */
private void assertWaiting(MchCheckoutBillMerchantEntity row, Integer version) {
if (row == null || !Objects.equals(row.getVersion(), version) || !"WAIT_PAY".equals(row.getState())) throw new IllegalStateException("商户结账状态或版本已变化,请刷新后重试");
}
/** 同幂等号不同参数明确拒绝。 */
private void assertDigest(String expected, String actual) {
if (!Objects.equals(expected, actual)) throw new IllegalStateException("同一请求号对应了不同的业务内容,请重新发起操作");
}
/** 单行写入失败整体回滚。 */
private void requireWrite(int count) {
if (count != 1) throw new IllegalStateException("结账状态写入失败");
}
/** 可选文本统一去除首尾空格。 */
private String trim(String value) {
return value == null ? "" : value.trim();
}
/** 凭证只保留明确填写的地址,前后顺序进入请求摘要。 */
private List<String> normalizedPics(List<String> values) {
return values == null ? List.of() : values.stream().map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
}
/** 数据库分页清除外部排序并限制100条。 */
private <T> Page<T> safe(Page<T> page) {
page.setCurrent(Math.max(1, Math.min(page.getCurrent(), 1000000)));
page.setSize(Math.max(1, Math.min(page.getSize(), 100)));
page.orders().clear();
return page;
}
/** 已分页实体转换成独立VO,保持数据库总数。 */
private <E, V> Page<V> viewPage(Page<E> data, Class<V> type) {
Page<V> page = new Page<>(data.getCurrent(), data.getSize(), data.getTotal());
page.setRecords(data.getRecords().stream().map(row -> MchCheckoutBillPreviewService.copy(row, type)).toList());
return page;
}
}
@@ -0,0 +1,138 @@
package com.bsd.pjcloud.wallet.service.impl.mch;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.dto.mch.MchSceneDto;
import com.bsd.pjcloud.wallet.api.entity.mch.MchSceneEntity;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchSceneVo;
import com.bsd.pjcloud.wallet.mapper.mch.MchSceneMapper;
import com.bsd.pjcloud.wallet.service.MchManageService;
import com.bsd.pjcloud.wallet.service.MchSceneService;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
/**
* 商户场景配置实现,依靠租户、商户和场景唯一键维护唯一配置。
*/
@Service
@RequiredArgsConstructor
public class MchSceneServiceImpl extends ServiceImpl<MchSceneMapper, MchSceneEntity> implements MchSceneService {
/** 酒店抽成场景的持久化编码,首期仅支持该场景。 */
private static final String HOTEL_COMMISSION_SCENE = "HOTEL_COMMISSION";
/** 首次保存未指定金额时使用的固定抽成金额,单位元。 */
private static final BigDecimal DEFAULT_COMMISSION_AMOUNT = new BigDecimal("0.00");
/** 固定抽成金额上限,单位元,与数据库 DECIMAL(10,2) 的范围一致。 */
private static final BigDecimal MAX_COMMISSION_AMOUNT = new BigDecimal("99999999.99");
/** 商户服务,复用当前登录用户的数据范围校验。 */
private final MchManageService mchManageService;
/**
* 校验商户访问范围和场景编码后读取唯一配置,未配置时返回空。
*
* @param mchId 商户 ID
* @param sceneCode 场景持久化编码
* @return 当前商户场景配置,未配置时返回空
*/
@Override
public MchSceneVo getForManagement(Long mchId, String sceneCode) {
validateSceneCode(sceneCode);
validateMerchantScope(mchId);
MchSceneEntity entity = baseMapper.selectExact(mchId, sceneCode);
return entity == null ? null : BeanUtils.toBean(entity, MchSceneVo.class);
}
/**
* 单行写入由 Mapper 原子提交,管理接口不启用跨语句事务。
* 并发首次保存遇到重复键后重新读取已提交记录,再更新配置和公共审计字段。
* 老客户端未传金额时保留原值,首次创建时使用零金额。
*
* @param dto 管理端场景开关和固定抽成金额
* @return 本次保存后的唯一配置
*/
@Override
public MchSceneVo saveOrUpdateConfig(MchSceneDto dto) {
validateSceneCode(dto.getSceneCode());
validateMerchantScope(dto.getMchId());
if (!Objects.equals(dto.getEnabled(), YesNoEnum.YES.getCode())
&& !Objects.equals(dto.getEnabled(), YesNoEnum.NO.getCode())) {
throw new IllegalArgumentException("是否开启只能为0或1");
}
BigDecimal commissionAmount = dto.getCommissionAmount();
if (commissionAmount != null && (commissionAmount.signum() < 0 || commissionAmount.scale() > 2
|| commissionAmount.compareTo(MAX_COMMISSION_AMOUNT) > 0)) {
throw new IllegalArgumentException("抽成金额必须为0至99999999.99元,最多保留两位小数");
}
MchSceneEntity target = baseMapper.selectExact(dto.getMchId(), dto.getSceneCode());
if (target == null) {
target = new MchSceneEntity();
target.setMchId(dto.getMchId());
target.setSceneCode(dto.getSceneCode());
target.setEnabled(dto.getEnabled());
target.setCommissionAmount(commissionAmount == null ? DEFAULT_COMMISSION_AMOUNT : commissionAmount);
try {
if (baseMapper.insert(target) != 1) {
throw new IllegalStateException("新增场景配置失败");
}
return BeanUtils.toBean(target, MchSceneVo.class);
} catch (DuplicateKeyException exception) {
target = baseMapper.selectExact(dto.getMchId(), dto.getSceneCode());
if (target == null) {
throw new IllegalStateException("场景配置已发生变化,请刷新后重试", exception);
}
}
}
MchSceneEntity changes = new MchSceneEntity();
changes.setId(target.getId());
changes.setEnabled(dto.getEnabled());
changes.setCommissionAmount(commissionAmount);
if (baseMapper.updateById(changes) != 1) {
throw new IllegalStateException("修改场景配置失败,请刷新后重试");
}
target.setEnabled(dto.getEnabled());
if (commissionAmount != null) {
target.setCommissionAmount(commissionAmount);
}
return BeanUtils.toBean(target, MchSceneVo.class);
}
/**
* 复用商户列表的数据范围能力,父店与分店按各自 ID 单独验证访问权限。
*
* @param mchId 待配置商户 ID
*/
private void validateMerchantScope(Long mchId) {
if (mchId == null) {
throw new IllegalArgumentException("商户不能为空");
}
List<MchManageVo> merchants = mchManageService.scopeList(MchManageDto.builder().ids(List.of(mchId)).build(), DataScope.of().only(true));
if (merchants == null || merchants.stream().noneMatch(item -> Objects.equals(item.getId(), mchId))) {
throw new IllegalArgumentException("无权配置当前商户");
}
}
/**
* 校验已支持的持久化场景编码,查询和保存共用同一协议。
*
* @param sceneCode 场景编码,首期只支持酒店抽成
*/
private void validateSceneCode(String sceneCode) {
if (!Objects.equals(HOTEL_COMMISSION_SCENE, sceneCode)) {
throw new IllegalArgumentException("当前仅支持酒店抽成场景");
}
}
}
@@ -0,0 +1,57 @@
package com.bsd.pjcloud.wallet.support.mch;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.UUID;
/** 10分钟候选与预览缓存;Redis组件自动加租户命名空间。 */
@Component
@RequiredArgsConstructor
public class MchCheckoutBillCache {
/** 临时工作集业务命名空间。 */
private static final String KEY_PREFIX = "mch-checkout:hotel:snapshot:";
/** 工作集最长存活时间,十分钟。 */
private static final Duration TTL = Duration.ofMinutes(10);
/** 已有字符串Redis组件,自动应用租户前缀。 */
private final StringRedisTemplate redis;
/** 已配置时间与长整数协议的统一JSON映射器。 */
private final ObjectMapper json;
/** 保存当前操作者工作集,缓存失败时阻止继续提交。 */
public void save(MchCheckoutBillSnapshot snapshot) {
if (SecurityUtils.getUser() == null) throw new IllegalStateException("缺少有效的管理用户上下文");
snapshot.setOwnerId(SecurityUtils.getUser().getId());
snapshot.setToken(UUID.randomUUID().toString());
snapshot.setExpiresAt(LocalDateTime.now().plus(TTL));
try {
redis.opsForValue().set(KEY_PREFIX + snapshot.getToken(), json.writeValueAsString(snapshot), TTL);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("结账预览缓存写入失败,请重试", exception);
}
}
/** 读取本人有效工作集,类别、过期时间及归属全部校验。 */
public MchCheckoutBillSnapshot require(String token, boolean preview) {
if (SecurityUtils.getUser() == null) throw new IllegalStateException("缺少有效的管理用户上下文");
if (token == null || !token.matches("[0-9a-fA-F-]{36}")) throw new IllegalArgumentException("结账预览令牌无效");
String value = redis.opsForValue().get(KEY_PREFIX + token);
if (value == null) throw new IllegalStateException("结账预览已过期,请重新查询并核对");
try {
MchCheckoutBillSnapshot snapshot = json.readValue(value, MchCheckoutBillSnapshot.class);
if (snapshot.isPreview() != preview || snapshot.getExpiresAt() == null || !snapshot.getExpiresAt().isAfter(LocalDateTime.now())
|| !Objects.equals(snapshot.getOwnerId(), SecurityUtils.getUser().getId())) {
throw new IllegalStateException("结账预览无效或不属于当前用户,请重新查询");
}
return snapshot;
} catch (JsonProcessingException exception) {
throw new IllegalStateException("结账预览读取失败,请重新查询", exception);
}
}
}
@@ -0,0 +1,100 @@
package com.bsd.pjcloud.wallet.support.mch;
import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutBillMerchantEntity;
import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutRelationEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutRelationMerchantVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutRelationVo;
import com.bsd.pjcloud.wallet.mapper.mch.*;
import com.bsd.pjcloud.wallet.service.MchCheckoutRelationService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/** 结账关系完整权限及商户部门快照,复用现有范围计算和关系服务。 */
@Component
@RequiredArgsConstructor
public class MchCheckoutBillContext {
/** 已有关系读取服务,负责完整数据权限及部门名称。 */
private final MchCheckoutRelationService relationService;
/** 现有关系行锁及版本能力。 */
private final MchCheckoutRelationMapper relationMapper;
/** 现有商户及绑定读取能力。 */
private final MchCheckoutRelationMerchantMapper memberMapper;
/** 参与结账的部门唯一性检查。 */
private final MchCheckoutBillMerchantMapper billMemberMapper;
/** 统一管理用户数据权限。 */
private final MchCheckoutRelationAccess access;
/** 规范化快照摘要。 */
private final MchCheckoutBillHotelSource source;
/** 在一致快照中读取完整关系并拒绝失效、空部门及归属歧义。 */
@Transactional(readOnly = true)
public MchCheckoutBillSnapshot read(Long relationId, Integer version) {
MchCheckoutRelationVo relation = relationService.detail(relationId);
if (!Objects.equals(relation.getVersion(), version)) throw new IllegalStateException("结账关系已变化,请刷新页面");
List<MchCheckoutBillMerchantEntity> members = members(relation.getMerchants(), access.requireScope());
assertUniqueDepartments(members);
MchCheckoutBillSnapshot result = new MchCheckoutBillSnapshot();
result.setRelationId(relationId);
result.setRelationVersion(version);
result.setIntermediaryName(relation.getIntermediaryName());
result.setMerchants(members);
return result;
}
/** 提交事务内先锁关系,再按商户ID锁定并读取当前归属,防止旧快照覆盖变更。 */
@Transactional(propagation = Propagation.MANDATORY)
public void lockAndValidate(MchCheckoutBillSnapshot snapshot, MchCheckoutRelationScope scope) {
MchCheckoutRelationEntity relation = relationMapper.lockRelation(snapshot.getRelationId());
if (relation == null || !Objects.equals(relation.getVersion(), snapshot.getRelationVersion())) throw new IllegalStateException("结账关系已变化,请重新预览");
List<Long> ids = memberMapper.listMembers(relation.getId()).stream().map(MchCheckoutRelationMerchantVo::getMerchantId).sorted().toList();
List<MchCheckoutRelationMerchantVo> current = memberMapper.listExact(ids, true);
if (current.size() != ids.size() || current.stream().anyMatch(row -> !Objects.equals(row.getBoundRelationId(), relation.getId()))) throw new IllegalStateException("结账关系商户绑定已变化");
List<MchCheckoutBillMerchantEntity> locked = members(current, scope);
assertUniqueDepartments(locked);
if (!membershipHash(locked).equals(membershipHash(snapshot.getMerchants()))) throw new IllegalStateException("商户名称、归属或权限资料已变化,请重新预览");
}
/** 读取临时工作集时再次校验当前关系,撤权和关系编辑立即使工作集失效。 */
public void validateSnapshot(MchCheckoutBillSnapshot snapshot) {
MchCheckoutBillSnapshot current = read(snapshot.getRelationId(), snapshot.getRelationVersion());
if (!membershipHash(current.getMerchants()).equals(membershipHash(snapshot.getMerchants()))) throw new IllegalStateException("关系商户资料已变化,请重新查询");
}
/** 摘要包含完整权限归属,不依赖商户分页或当前金额。 */
public String membershipHash(List<MchCheckoutBillMerchantEntity> members) {
return source.hash(members.stream().sorted(Comparator.comparing(MchCheckoutBillMerchantEntity::getMerchantId))
.map(row -> Arrays.asList(row.getMerchantId(), row.getMerchantName(), row.getDeptId(), row.getMerchantCreateBy())).toList());
}
/** 构造全部商户权限快照,任何异常成员都使本次发起失败。 */
private List<MchCheckoutBillMerchantEntity> members(List<MchCheckoutRelationMerchantVo> rows, MchCheckoutRelationScope scope) {
if (rows == null || rows.isEmpty() || rows.size() > 1000) throw new IllegalStateException("结账关系商户名单无效");
List<MchCheckoutBillMerchantEntity> result = new ArrayList<>();
for (MchCheckoutRelationMerchantVo row : rows) {
if (!scope.allows(row) || row.getRecordId() == null || !"0".equals(row.getMerchantDelFlag()) || row.getDeptId() == null || row.getMerchantName() == null || row.getMerchantName().isBlank()) throw new IllegalStateException("结账关系包含无权、失效、部门或名称不完整的商户,请先维护关系");
MchCheckoutBillMerchantEntity member = new MchCheckoutBillMerchantEntity();
member.setMerchantId(row.getMerchantId());
member.setMerchantName(row.getMerchantName());
member.setDeptId(row.getDeptId());
member.setDeptName(row.getDeptName());
member.setMerchantCreateBy(row.getMerchantCreateBy());
member.setOrderCount(0);
member.setGrossAmountFen(0L);
member.setFeeAmountFen(0L);
member.setPayableAmountFen(0L);
member.setState("NO_ORDERS");
member.setVersion(0);
result.add(member);
}
return result;
}
/** 同部门多商户会造成收款归属歧义,必须在结账前清理。 */
private void assertUniqueDepartments(List<MchCheckoutBillMerchantEntity> members) {
List<Long> ids = members.stream().map(MchCheckoutBillMerchantEntity::getDeptId).toList();
if (new HashSet<>(ids).size() != ids.size() || billMemberMapper.ambiguousDepartment(ids) != null) throw new IllegalStateException("参与结账的部门对应多个绑定商户,请先明确唯一归属");
}
}
@@ -0,0 +1,112 @@
package com.bsd.pjcloud.wallet.support.mch;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.supermarket.api.feign.RemoteSmHotelOrderCheckoutQueryService;
import com.bsd.pjcloud.supermarket.dto.SmHotelOrderCheckoutQueryDto;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.util.*;
/** 酒店只读适配,严格校验完整批次,禁止把异常或漏单当成空结果。 */
@Component
@RequiredArgsConstructor
public class MchCheckoutBillHotelSource {
/** 单次完整工作集上限,超量明确拆批。 */
public static final int MAX_ORDERS = 10000;
/** Feign及后台核对的单批订单上限。 */
public static final int BATCH_SIZE = 500;
/** 酒店独立只读Feign。 */
private final RemoteSmHotelOrderCheckoutQueryService remote;
/** 稳定源事实序列化。 */
private final ObjectMapper json;
/** 完整扫描部门及周期范围,后续批次必须保持首批主键上界。 */
public List<SmHotelOrderCheckoutQueryVo> scan(List<Long> departments, LocalDate start, LocalDate end) {
SmHotelOrderCheckoutQueryDto.Scan request = new SmHotelOrderCheckoutQueryDto.Scan();
request.setDeptIds(departments);
request.setPeriodStart(start);
request.setPeriodEnd(end);
List<SmHotelOrderCheckoutQueryVo> rows = new ArrayList<>();
Set<Long> seen = new HashSet<>();
while (true) {
SmHotelOrderCheckoutQueryVo.ScanResult result = require(remote.scan(request));
if (result.getUpperId() == null || result.getRecords() == null || result.getRecords().size() > BATCH_SIZE
|| (request.getUpperId() != null && !request.getUpperId().equals(result.getUpperId()))) {
throw new IllegalStateException("酒店订单扫描协议异常,请重试");
}
request.setUpperId(result.getUpperId());
for (SmHotelOrderCheckoutQueryVo row : result.getRecords()) {
if (row.getOrderId() == null || row.getOrderId() <= request.getAfterId() || row.getOrderId() > request.getUpperId() || !seen.add(row.getOrderId())) {
throw new IllegalStateException("酒店订单游标异常,请重试");
}
request.setAfterId(row.getOrderId());
rows.add(row);
if (rows.size() > MAX_ORDERS) throw new IllegalArgumentException("周期内酒店订单超过10000笔,请缩小周期或拆分结账");
}
if (result.getRecords().size() < BATCH_SIZE) return rows;
}
}
/** 分批读取全部明确主键,缺失项也必须有逐项响应。 */
public Map<Long, SmHotelOrderCheckoutQueryVo> fetch(List<Long> ids) {
Map<Long, SmHotelOrderCheckoutQueryVo> result = new LinkedHashMap<>();
for (int start = 0; start < ids.size(); start += BATCH_SIZE) {
List<Long> batch = ids.subList(start, Math.min(ids.size(), start + BATCH_SIZE));
SmHotelOrderCheckoutQueryDto.ByIds request = new SmHotelOrderCheckoutQueryDto.ByIds();
request.setOrderIds(batch);
Set<Long> expected = new HashSet<>(batch);
for (SmHotelOrderCheckoutQueryVo row : require(remote.byIds(request))) {
if (row.getOrderId() == null || !expected.remove(row.getOrderId()) || result.put(row.getOrderId(), row) != null) {
throw new IllegalStateException("酒店订单批量复核出现重复或额外记录");
}
}
if (!expected.isEmpty()) throw new IllegalStateException("酒店订单批量复核漏单,请重试");
}
return result;
}
/** 只使用影响结账的事实计算摘要,联系人及无关补偿日志不参与。 */
public String fingerprint(SmHotelOrderCheckoutQueryVo row) {
return hash(Arrays.asList(row.getOrderId(), row.isFound(), row.getResolutionStatus(), row.getHotelId(), row.getDeptId(),
row.getCheckInDate(), row.getCheckOutDate(), row.getRooms(), row.getSellAmountYuan(), row.getOrderStatus(), row.getBookStatus(),
row.getPayStatus(), row.getPartnerCode(), row.getDownstreamAppId(), row.getChannelNo(), row.getPlatformNo(), row.getHotelDataSource()));
}
/** 确定性JSON摘要,用于源事实和客户端请求幂等。 */
public String hash(Object value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(json.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsBytes(value));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException | JsonProcessingException exception) {
throw new IllegalStateException("结账内容摘要计算失败", exception);
}
}
/** 返回不可进入人工核对池的原因,空字符串表示允许人工确认。 */
public String eligibility(SmHotelOrderCheckoutQueryVo row) {
if (!row.isFound()) return "酒店订单已删除或不存在";
if (!"OK".equals(row.getResolutionStatus()) || row.getDeptId() == null) return "酒店归属缺失或无法唯一确定";
if (!Set.of("UPSTREAM_OK", "CHECKED_IN").contains(Objects.toString(row.getOrderStatus(), ""))) return "订单尚未成功履约或已经取消、失败";
String pay = Objects.toString(row.getPayStatus(), "").trim().toUpperCase(Locale.ROOT);
if (Set.of("UNPAID", "REFUNDED", "REFUNDING", "REFUND_PROCESSING").contains(pay) || "X".equalsIgnoreCase(row.getBookStatus())) return "订单明确未支付、退款中或已取消退款";
try {
if (MchCheckoutBillMoney.fen(row.getSellAmountYuan(), "订单金额") <= 0) return "订单金额必须大于0";
} catch (IllegalArgumentException exception) {
return exception.getMessage();
}
return "";
}
/** 远程业务失败或空数据必须传播,不能当作空订单集合。 */
private <T> T require(R<T> response) {
if (response == null || !response.isOk() || response.getData() == null) throw new IllegalStateException("酒店订单读取失败,请重试");
return response.getData();
}
}
@@ -0,0 +1,71 @@
package com.bsd.pjcloud.wallet.support.mch;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.wallet.api.entity.mch.*;
import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutBillVo;
import com.bsd.pjcloud.wallet.mapper.mch.*;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.*;
/** 财务账本的授权、状态汇总和追加式审计,写入由调用方本地事务保护。 */
@Component
@RequiredArgsConstructor
public class MchCheckoutBillLedger {
/** 账单主体访问。 */
private final MchCheckoutBillMapper bills;
/** 全部商户明细访问。 */
private final MchCheckoutBillMerchantMapper merchants;
/** 追加式审计访问。 */
private final MchCheckoutBillEventMapper events;
/** 审计JSON编码。 */
private final ObjectMapper json;
/** 规范化请求摘要能力。 */
private final MchCheckoutBillHotelSource source;
/** 同一错误覆盖无权和不存在,避免泄露历史中间方信息。 */
public MchCheckoutBillVo requireVisible(Long id, MchCheckoutRelationScope scope) {
MchCheckoutBillVo bill = bills.findVisible(id, scope);
if (bill == null) throw new IllegalStateException("结账单不存在或没有完整数据权限");
return bill;
}
/** 在账单主体锁内汇总有效明细,原始金额快照保持原值。 */
public void refreshState(MchCheckoutBillEntity bill) {
List<MchCheckoutBillMerchantEntity> rows = merchants.selectList(Wrappers.<MchCheckoutBillMerchantEntity>lambdaQuery().eq(MchCheckoutBillMerchantEntity::getBillId, bill.getId()));
long waiting = rows.stream().filter(row -> "WAIT_PAY".equals(row.getState())).count();
long paid = rows.stream().filter(row -> "PAID".equals(row.getState())).count();
String state = waiting > 0 ? (paid > 0 ? "PART_PAID" : "WAIT_PAY") : (paid > 0 ? "FINISHED" : "VOID");
bill.setState(state);
bill.setVersion(Math.addExact(bill.getVersion(), 1));
bill.setUpdateTime(LocalDateTime.now());
if (bills.updateById(bill) != 1) throw new IllegalStateException("账单状态更新失败");
}
/** 在当前业务事务中记录依据和结果,事件与状态一起提交。 */
public void event(Long billId, Long merchantRowId, Long orderId, String action, String requestId, Object payload) {
MchCheckoutBillEventEntity event = new MchCheckoutBillEventEntity();
event.setBillId(billId);
event.setBillMerchantId(merchantRowId);
event.setSourceOrderId(orderId);
event.setAction(action);
event.setRequestId(requestId);
event.setRequestHash(source.hash(payload));
event.setPayload(encode(payload));
event.setCreateBy(SecurityUtils.getUser() == null ? "system" : SecurityUtils.getUser().getUsername());
if (events.insert(event) != 1) throw new IllegalStateException("结账审计事件写入失败");
}
/** 编码限定的业务模型,JSON错误会回滚当前事务。 */
public String encode(Object value) {
try {
return json.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("结账业务依据编码失败", exception);
}
}
}
@@ -0,0 +1,38 @@
package com.bsd.pjcloud.wallet.support.mch;
import java.math.BigDecimal;
import java.math.RoundingMode;
/** 结账精确金额运算,所有持久化与汇总均使用分。 */
public final class MchCheckoutBillMoney {
/** 工具类不创建实例。 */
private MchCheckoutBillMoney() { }
/** 将元精确转换为分,超精度、负值及溢出明确拒绝。 */
public static long fen(String yuan, String field) {
if (yuan == null || yuan.isBlank()) throw new IllegalArgumentException(field + "缺失");
try {
long value = new BigDecimal(yuan).setScale(2, RoundingMode.UNNECESSARY).movePointRight(2).longValueExact();
if (value < 0) throw new IllegalArgumentException(field + "不能为负数");
return value;
} catch (NumberFormatException | ArithmeticException exception) {
throw new IllegalArgumentException(field + "必须精确到分且在支持范围内", exception);
}
}
/** 正价订单减去固定每单抽成,结果可以为零。 */
public static long payable(long gross, long fee) {
if (gross <= 0) throw new IllegalArgumentException("订单金额必须大于0");
if (fee < 0 || fee > gross) throw new IllegalArgumentException("每单抽成不能超过任一所选订单金额");
return Math.subtractExact(gross, fee);
}
/** 安全加总分金额,溢出时整次计算失败。 */
public static long add(long left, long right) {
try {
return Math.addExact(left, right);
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("结账合计超出金额支持范围,请拆分结账", exception);
}
}
}
@@ -0,0 +1,43 @@
package com.bsd.pjcloud.wallet.support.mch;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutBillMerchantEntity;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 钱包内部临时工作集,通过令牌访问,完整名单与源事实不依赖单页结果。 */
@Data
public class MchCheckoutBillSnapshot {
/** 令牌所属管理用户,禁止跨用户读取。 */
private Long ownerId;
/** 随机令牌,仅作为临时工作集身份。 */
private String token;
/** 是否为已经人工核对的确认预览。 */
private boolean preview;
/** 原结账关系主键。 */
private Long relationId;
/** 原结账关系版本。 */
private Integer relationVersion;
/** 原中间方名称。 */
private String intermediaryName;
/** 离店周期起点,包含当天。 */
private LocalDate periodStart;
/** 离店周期终点,包含当天。 */
private LocalDate periodEnd;
/** 服务端令牌过期时间。 */
private LocalDateTime expiresAt;
/** 最近一次完整读取或复核源事实的时间。 */
private LocalDateTime sourceVerifiedAt;
/** 每单抽成,单位分,候选为0。 */
private Long feePerOrderFen;
/** 本次结账备注。 */
private String remark;
/** 创建内容摘要,包含明确名单、源事实及关系完整归属快照。 */
private String requestHash;
/** 全部关系成员权限快照,预览包含各商户金额。 */
private List<MchCheckoutBillMerchantEntity> merchants;
/** 完整源事实,最多10000笔,预览仅包含明确选择的订单。 */
private List<SmHotelOrderCheckoutQueryVo> orders;
}
@@ -46,51 +46,85 @@
<sql id="whereA">
</sql>
<!-- 普通列表、导出及余额排序共用最新有效流水规则,避免切换排序后同一钱包金额变化。 -->
<sql id="qbLatestBalanceRows">
SELECT tx.user_id,
tx.wallet_id,
source_wallet.wallet_type_id,
tx.balance,
tx.create_time,
ROW_NUMBER() OVER (
PARTITION BY tx.user_id, source_wallet.wallet_type_id
ORDER BY tx.create_time DESC, tx.id DESC
) AS row_num
FROM wallet_transaction tx
INNER JOIN wallet_manage source_wallet ON source_wallet.id = tx.wallet_id
WHERE tx.del_flag = '0'
AND tx.create_time &lt; #{monthEndExclusive}
<if test="dto.userId != null">
AND tx.user_id = #{dto.userId}
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
AND tx.user_id IN
<foreach collection="dto.userIds" item="userId" open="(" close=")" separator=",">
#{userId}
</foreach>
</if>
<if test="dto.walletTypeId != null">
AND source_wallet.wallet_type_id = #{dto.walletTypeId}
</if>
</sql>
<!--
余额排序先在数据库取各用户、钱包类型的月末最新流水,再应用钱包筛选和分页。
窗口只扫描一次符合条件的流水,避免逐钱包相关子查询;同时间流水按主键确定唯一结果。
历史钱包不额外过滤删除标记,保留原先按用户与钱包类型查询历史余额的口径。
外层保留部门、创建人字段供数据权限拦截器使用,最终排序由分页插件在权限外层追加。
-->
<select id="selectQbBalancePage" resultType="com.bsd.pjcloud.wallet.api.vo.WalletManageVo">
SELECT monthly_wallet.*
FROM (
SELECT wallet.id,
wallet.user_id,
wallet.wallet_type_id,
wallet.dept_id,
wallet.card_number,
COALESCE(latest.balance, 0) AS balance,
0 AS total_expense,
0 AS total_recharge,
wallet.freeze_price,
wallet.is_freeze,
wallet.active_bind_time,
wallet.status,
wallet.create_by,
wallet.create_time,
wallet.update_by,
wallet.update_time,
wallet.del_flag,
wallet.tenant_id,
latest.create_time AS last_top_up_time
FROM wallet_manage wallet
LEFT JOIN (
<include refid="qbLatestBalanceRows"/>
) latest ON latest.user_id = wallet.user_id
AND latest.wallet_type_id = wallet.wallet_type_id
AND latest.row_num = 1
WHERE wallet.del_flag = '0'
) monthly_wallet
${ew.customSqlSegment}
</select>
<select id="selectBalance" resultType="com.bsd.pjcloud.wallet.api.vo.WalletManageBalanceVo">
SELECT a.user_id,
a.balance,
a.wallet_id,
m.wallet_type_id,
a.create_time
FROM wallet_transaction a
LEFT JOIN wallet_manage m on a.wallet_id = m.id
<where>
a.del_flag = 0
<if test="dto.walletRecordMonth != null">
AND a.create_time = (
SELECT MAX(sub.create_time)
FROM wallet_transaction sub
LEFT JOIN wallet_manage wallet on wallet.id = sub.wallet_id
WHERE sub.user_id = a.user_id
AND DATE_FORMAT(sub.create_time, '%Y-%m') &lt;= #{dto.walletRecordMonth}
<if test="dto.userId != null">
AND sub.user_id = #{dto.userId}
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and sub.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.walletTypeId != null">
AND wallet.wallet_type_id = #{dto.walletTypeId}
</if>
)
</if>
<if test="dto.userId != null">
AND a.user_id = #{dto.userId}
</if>
<if test="dto.walletTypeId != null">
AND m.wallet_type_id = #{dto.walletTypeId}
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and a.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
</where>
ORDER BY a.user_id;
SELECT latest.user_id,
latest.balance,
latest.wallet_id,
latest.wallet_type_id,
latest.create_time
FROM (
<include refid="qbLatestBalanceRows"/>
) latest
WHERE latest.row_num = 1
ORDER BY latest.user_id
</select>
<sql id="scopeUser">
@@ -218,7 +252,7 @@
m.wallet_type_id,
FIRST_VALUE(a.balance) OVER (
PARTITION BY a.user_id, m.wallet_type_id
ORDER BY a.create_time DESC
ORDER BY a.create_time DESC, a.id DESC
) as balance
FROM wallet_transaction a
INNER JOIN wallet_manage m
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutBillMapper">
<!-- 完整关系成员均有快照行;零订单商户也参与整组历史权限判断。 -->
<sql id="visible">
1 = 1
<if test="!scope.all">
AND NOT EXISTS (SELECT 1 FROM mch_checkout_bill_merchant m WHERE m.bill_id = b.id AND NOT COALESCE((1 = 0
<if test="scope.deptIds != null and scope.deptIds.size() > 0">
OR m.dept_id IN <foreach collection="scope.deptIds" item="dept" open="(" separator="," close=")">#{dept}</foreach>
</if>
<if test="scope.username != null">OR m.merchant_create_by = #{scope.username}</if>
), 0))
</if>
</sql>
<sql id="columns">
b.*,
(SELECT COALESCE(SUM(m.payable_amount_fen), 0) FROM mch_checkout_bill_merchant m WHERE m.bill_id=b.id AND m.state != 'VOID') AS effective_payable_amount_fen,
(SELECT COALESCE(SUM(m.paid_amount_fen), 0) FROM mch_checkout_bill_merchant m WHERE m.bill_id=b.id AND m.state='PAID') AS paid_amount_fen,
(SELECT COALESCE(SUM(m.payable_amount_fen), 0) FROM mch_checkout_bill_merchant m WHERE m.bill_id=b.id AND m.state='WAIT_PAY') AS pending_amount_fen,
(SELECT COALESCE(SUM(m.payable_amount_fen), 0) FROM mch_checkout_bill_merchant m WHERE m.bill_id=b.id AND m.state='VOID') AS void_amount_fen,
(SELECT COUNT(*) FROM mch_checkout_bill_merchant m WHERE m.bill_id=b.id AND m.state='VOID') AS void_merchant_count,
(SELECT COUNT(*) FROM mch_checkout_bill_order o JOIN mch_checkout_bill_merchant m ON m.id=o.bill_merchant_id
WHERE o.bill_id=b.id AND m.state != 'VOID' AND o.last_check_status IN ('DIFF','QUERY_FAILED')) AS anomaly_count
</sql>
<select id="pageVisible" resultType="com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutBillVo">
SELECT <include refid="columns"/> FROM mch_checkout_bill b WHERE <include refid="visible"/>
<if test="query.intermediaryName != null and query.intermediaryName != ''">AND b.intermediary_name LIKE CONCAT('%',#{query.intermediaryName},'%')</if>
<if test="query.billNo != null and query.billNo != ''">AND b.bill_no LIKE CONCAT('%',#{query.billNo},'%')</if>
<if test="query.state != null and query.state != ''">AND b.state=#{query.state}</if>
ORDER BY b.create_time DESC,b.id DESC
</select>
<select id="findVisible" resultType="com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutBillVo">
SELECT <include refid="columns"/> FROM mch_checkout_bill b WHERE b.id=#{id} AND <include refid="visible"/>
</select>
<select id="lockBill" resultType="com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutBillEntity">
SELECT * FROM mch_checkout_bill WHERE id=#{id} FOR UPDATE
</select>
</mapper>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutBillMerchantMapper">
<select id="lockMerchant" resultType="com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutBillMerchantEntity">
SELECT * FROM mch_checkout_bill_merchant WHERE bill_id=#{billId} AND merchant_id=#{merchantId} FOR UPDATE
</select>
<select id="ambiguousDepartment" resultType="java.lang.Long">
SELECT m.dept_id FROM mch_manage m
JOIN mch_checkout_relation_merchant binding ON binding.merchant_id=m.id
WHERE m.del_flag='0' AND m.dept_id IN
<foreach collection="deptIds" item="dept" open="(" separator="," close=")">#{dept}</foreach>
GROUP BY m.dept_id HAVING COUNT(DISTINCT m.id) &gt; 1 LIMIT 1
</select>
<insert id="insertRows">
INSERT INTO mch_checkout_bill_merchant (id, bill_id, merchant_id, merchant_name, dept_id, dept_name, merchant_create_by, order_count, gross_amount_fen, fee_amount_fen, payable_amount_fen, state, version, payment_time, paid_amount_fen, payment_no, payment_pics, payment_remark, payment_request_id, payment_request_hash, void_reason, void_request_id, void_request_hash, create_by, create_time, update_by, update_time)
VALUES <foreach collection="rows" item="row" separator=",">(#{row.id}, #{row.billId}, #{row.merchantId}, #{row.merchantName}, #{row.deptId}, #{row.deptName}, #{row.merchantCreateBy}, #{row.orderCount}, #{row.grossAmountFen}, #{row.feeAmountFen}, #{row.payableAmountFen}, #{row.state}, #{row.version}, #{row.paymentTime}, #{row.paidAmountFen}, #{row.paymentNo}, #{row.paymentPics}, #{row.paymentRemark}, #{row.paymentRequestId}, #{row.paymentRequestHash}, #{row.voidReason}, #{row.voidRequestId}, #{row.voidRequestHash}, #{row.createBy}, #{row.createTime}, #{row.updateBy}, #{row.updateTime})</foreach>
</insert>
</mapper>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutBillOrderMapper">
<select id="lockOrder" resultType="com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutBillOrderEntity">
SELECT * FROM mch_checkout_bill_order WHERE bill_id=#{billId} AND id=#{id} FOR UPDATE
</select>
<insert id="insertRows">
INSERT INTO mch_checkout_bill_order (id, bill_id, bill_merchant_id, merchant_id, source_type, source_order_id, hotel_id, hotel_name, dept_id, channel_no, platform_no, check_in_date, check_out_date, rooms, gross_amount_fen, fee_amount_fen, payable_amount_fen, order_status, book_status, pay_status, source_updated_at, source_fingerprint, source_snapshot, confirmed_user_id, confirmed_by, confirmed_at, last_check_status, last_check_message, last_observed_fingerprint, handled_fingerprint, last_checked_at, last_verified_at, next_check_at, create_by, create_time, update_by, update_time)
VALUES <foreach collection="rows" item="row" separator=",">(#{row.id}, #{row.billId}, #{row.billMerchantId}, #{row.merchantId}, #{row.sourceType}, #{row.sourceOrderId}, #{row.hotelId}, #{row.hotelName}, #{row.deptId}, #{row.channelNo}, #{row.platformNo}, #{row.checkInDate}, #{row.checkOutDate}, #{row.rooms}, #{row.grossAmountFen}, #{row.feeAmountFen}, #{row.payableAmountFen}, #{row.orderStatus}, #{row.bookStatus}, #{row.payStatus}, #{row.sourceUpdatedAt}, #{row.sourceFingerprint}, #{row.sourceSnapshot}, #{row.confirmedUserId}, #{row.confirmedBy}, #{row.confirmedAt}, #{row.lastCheckStatus}, #{row.lastCheckMessage}, #{row.lastObservedFingerprint}, #{row.handledFingerprint}, #{row.lastCheckedAt}, #{row.lastVerifiedAt}, #{row.nextCheckAt}, #{row.createBy}, #{row.createTime}, #{row.updateBy}, #{row.updateTime})</foreach>
</insert>
</mapper>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutOrderClaimMapper">
<insert id="insertRows">
INSERT INTO mch_checkout_order_claim (id, source_type, source_order_id, bill_id, bill_merchant_id, create_by, create_time)
VALUES <foreach collection="rows" item="row" separator=",">(#{row.id}, #{row.sourceType}, #{row.sourceOrderId}, #{row.billId}, #{row.billMerchantId}, #{row.createBy}, #{row.createTime})</foreach>
</insert>
</mapper>
@@ -0,0 +1,168 @@
package com.bsd.pjcloud.wallet.mapper.mch;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import java.nio.file.*;
import java.sql.*;
import java.util.*;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
/** 真实MySQL财务约束和行锁回归,只在显式指定的隔离测试库中执行。 */
@EnabledIfEnvironmentVariable(named = "CHECKOUT_BILL_TEST_JDBC_URL", matches = "jdbc:mysql:.*")
class MchCheckoutBillDatabaseTest {
/** 实际迁移中的五张表名,本次均替换为唯一随机表名。 */
private static final List<String> TABLES = List.of("mch_checkout_bill", "mch_checkout_bill_merchant", "mch_checkout_bill_order", "mch_checkout_order_claim", "mch_checkout_bill_event");
/** 仅允许清理本测试创建的随机表。 */
private static final Map<String, String> fixtures = new LinkedHashMap<>();
/** 通过隔离测试库名称检查后才允许建表。 */
private static boolean fixtureAllowed;
/** 读取实际迁移并重复建表,校验脚本可重跑。 */
@BeforeAll
static void createFixture() throws Exception {
String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
Path cursor = Path.of(System.getProperty("user.dir")).toAbsolutePath();
while (cursor != null && !Files.exists(cursor.resolve("db/20261109_add_mch_checkout_bill.sql"))) cursor = cursor.getParent();
assertNotNull(cursor, "从源码工程执行以读取实际迁移");
String sql = Files.readString(cursor.resolve("db/20261109_add_mch_checkout_bill.sql")).replaceAll("(?m)^--.*$", "");
for (String table : TABLES) {
fixtures.put(table, table + "_" + suffix);
sql = sql.replace("`" + table + "`", "`" + fixtures.get(table) + "`");
}
sql = sql.replaceAll("CONSTRAINT (ck_checkout_[a-z_]+)", "CONSTRAINT $1_" + suffix);
try (Connection connection = connection()) {
assertNotNull(connection.getCatalog());
assertTrue(connection.getCatalog().endsWith("_checkout_bill_test"), "测试库名必须以_checkout_bill_test结尾");
fixtureAllowed = true;
for (int repeat = 0; repeat < 2; repeat++) {
for (String statement : sql.split(";")) if (!statement.isBlank()) execute(connection, statement);
}
}
}
/** 清理范围仅限本次生成的随机表。 */
@AfterAll
static void dropFixture() throws Exception {
if (!fixtureAllowed) return;
try (Connection connection = connection()) {
for (String table : fixtures.values()) execute(connection, "DROP TABLE IF EXISTS `" + table + "`");
}
}
/** 两个真实连接并发占用同一源订单,唯一键只允许一方提交。 */
@Test
void concurrentOrderClaimsShouldAllowOneWinner() throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
try (Connection first = connection()) {
first.setAutoCommit(false);
execute(first, claim(101, 901, 1, 11));
CountDownLatch attempting = new CountDownLatch(1);
Future<String> other = executor.submit(() -> {
try (Connection second = connection()) {
attempting.countDown();
try { execute(second, claim(102, 901, 2, 22)); return "unexpected-success"; }
catch (SQLException exception) { return exception.getSQLState(); }
}
});
assertTrue(attempting.await(5, TimeUnit.SECONDS));
first.commit();
assertEquals("23000", other.get(10, TimeUnit.SECONDS));
assertEquals(1, scalar(first, "SELECT COUNT(*) FROM " + table("mch_checkout_order_claim") + " WHERE source_order_id=901"));
} finally { executor.shutdownNow(); }
}
/** 主表插入后的占用冲突回滚必须撤销账单,原占用保留。 */
@Test
void orderConflictShouldRollBackTheWholeBill() throws Exception {
try (Connection connection = connection()) {
execute(connection, claim(201, 902, 3, 33));
connection.setAutoCommit(false);
execute(connection, bill(202, "rollback-request"));
assertThrows(SQLException.class, () -> execute(connection, claim(203, 902, 202, 44)));
connection.rollback();
assertEquals(0, scalar(connection, "SELECT COUNT(*) FROM " + table("mch_checkout_bill") + " WHERE id=202"));
assertEquals(1, scalar(connection, "SELECT COUNT(*) FROM " + table("mch_checkout_order_claim") + " WHERE id=201"));
}
}
/** 真实行锁下付款与旧版本作废竞争,已付款明细保留原占用。 */
@Test
void paymentShouldPreventAConcurrentStaleVoid() throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
try (Connection first = connection()) {
execute(first, merchant(301, 300, 1));
execute(first, claim(302, 903, 300, 301));
first.setAutoCommit(false);
try (Statement statement = first.createStatement(); ResultSet result = statement.executeQuery("SELECT id FROM " + table("mch_checkout_bill_merchant") + " WHERE id=301 FOR UPDATE")) { assertTrue(result.next()); }
execute(first, "UPDATE " + table("mch_checkout_bill_merchant") + " SET state='PAID',paid_amount_fen=9500,payment_time=NOW(),version=1 WHERE id=301 AND version=0 AND state='WAIT_PAY'");
CountDownLatch attempting = new CountDownLatch(1);
Future<Integer> other = executor.submit(() -> {
try (Connection second = connection()) {
attempting.countDown();
return execute(second, "UPDATE " + table("mch_checkout_bill_merchant") + " SET state='VOID',version=1 WHERE id=301 AND version=0 AND state='WAIT_PAY'");
}
});
assertTrue(attempting.await(5, TimeUnit.SECONDS));
first.commit();
assertEquals(0, other.get(10, TimeUnit.SECONDS));
assertEquals(1, scalar(first, "SELECT COUNT(*) FROM " + table("mch_checkout_order_claim") + " WHERE bill_merchant_id=301"));
} finally { executor.shutdownNow(); }
}
/** 创建请求号和每层金额等式由真实数据库兜底。 */
@Test
void idempotencyAndAmountInvariantsShouldBeEnforced() throws Exception {
try (Connection connection = connection()) {
execute(connection, bill(401, "same-request"));
assertThrows(SQLException.class, () -> execute(connection, bill(402, "same-request")));
execute(connection, merchant(403, 401, 1));
assertThrows(SQLException.class, () -> execute(connection, "UPDATE " + table("mch_checkout_bill_merchant") + " SET payable_amount_fen=1 WHERE id=403"));
assertEquals(9500, scalar(connection, "SELECT payable_amount_fen FROM " + table("mch_checkout_bill_merchant") + " WHERE id=403"));
}
}
/** 同一源主键允许不同租户独立占用,同租户重复仍由唯一键拒绝。 */
@Test
void orderClaimsShouldBeIsolatedAcrossTenants() throws Exception {
try (Connection connection = connection()) {
execute(connection, claim(501, 904, 500, 5001));
execute(connection, "INSERT INTO " + table("mch_checkout_order_claim") + " (id,source_type,source_order_id,bill_id,bill_merchant_id,tenant_id) VALUES (502,'HOTEL',904,501,5002,2)");
assertThrows(SQLException.class, () -> execute(connection, claim(503, 904, 502, 5003)));
assertEquals(2, scalar(connection, "SELECT COUNT(*) FROM " + table("mch_checkout_order_claim") + " WHERE source_order_id=904"));
}
}
/** 仅使用显式测试环境变量,禁止读取应用实际数据库配置。 */
private static Connection connection() throws SQLException {
return DriverManager.getConnection(System.getenv("CHECKOUT_BILL_TEST_JDBC_URL"), Objects.toString(System.getenv("CHECKOUT_BILL_TEST_JDBC_USER"), ""), Objects.toString(System.getenv("CHECKOUT_BILL_TEST_JDBC_PASSWORD"), ""));
}
/** 仅返回本测试生成的标识符。 */
private static String table(String name) { return "`" + fixtures.get(name) + "`"; }
/** 执行固定测试SQL,返回影响行数。 */
private static int execute(Connection connection, String sql) throws SQLException {
try (Statement statement = connection.createStatement()) { return statement.executeUpdate(sql); }
}
/** 查询一个整型约束验证值。 */
private static long scalar(Connection connection, String sql) throws SQLException {
try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) { assertTrue(result.next()); return result.getLong(1); }
}
/** 构造固定酒店占用测试值。 */
private static String claim(long id, long orderId, long billId, long merchantRowId) {
return "INSERT INTO " + table("mch_checkout_order_claim") + " (id,source_type,source_order_id,bill_id,bill_merchant_id,tenant_id) VALUES (" + id + ",'HOTEL'," + orderId + "," + billId + "," + merchantRowId + ",1)";
}
/** 构造金额满足等式的商户明细。 */
private static String merchant(long id, long billId, long merchantId) {
return "INSERT INTO " + table("mch_checkout_bill_merchant") + " (id,bill_id,merchant_id,merchant_name,dept_id,order_count,gross_amount_fen,fee_amount_fen,payable_amount_fen,state,version,tenant_id) VALUES (" + id + "," + billId + "," + merchantId + ",'测试商户',11,1,10000,500,9500,'WAIT_PAY',0,1)";
}
/** 构造最小真实账单,参数均由本测试固定生成。 */
private static String bill(long id, String requestId) {
return "INSERT INTO " + table("mch_checkout_bill") + " (id,bill_no,source_type,relation_id,relation_version,intermediary_name,period_start,period_end,rule_version,fee_per_order_fen,order_count,merchant_count,gross_amount_fen,fee_amount_fen,payable_amount_fen,state,version,create_request_id,request_hash,preview_token,source_verified_at,tenant_id) VALUES (" + id + ",'TEST" + id + "','HOTEL',1,0,'测试结账人','2026-10-01','2026-10-31',1,500,1,1,10000,500,9500,'WAIT_PAY',0,'" + requestId + "','" + "0".repeat(64) + "','preview',NOW(),1)";
}
}
@@ -0,0 +1,64 @@
package com.bsd.pjcloud.wallet.mapper.mch;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutBillRequests;
import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutOrderClaimEntity;
import com.bsd.pjcloud.wallet.support.mch.MchCheckoutRelationScope;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/** 实际Mapper动态SQL回归,检查整组权限位置及批量VALUES租户补全。 */
class MchCheckoutBillScopeSqlTest {
/** 部门和本人权限都在数据库分页前通过完整商户快照限制。 */
@Test
void groupScopeShouldBeAppliedBeforePagination() throws Exception {
Configuration configuration = mapper("MchCheckoutBillMapper");
BoundSql department = configuration.getMappedStatement("com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutBillMapper.pageVisible")
.getBoundSql(Map.of("query", new MchCheckoutBillRequests.ListQuery(), "scope", new MchCheckoutRelationScope(false, List.of(11L), null)));
String sql = department.getSql();
assertTrue(sql.contains("NOT EXISTS"));
assertTrue(sql.contains("m.dept_id IN"));
assertTrue(sql.indexOf("NOT EXISTS") < sql.indexOf("ORDER BY"));
assertTrue(department.getParameterMappings().stream().anyMatch(parameter -> parameter.getProperty().contains("dept")));
BoundSql self = configuration.getMappedStatement("com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutBillMapper.pageVisible")
.getBoundSql(Map.of("query", new MchCheckoutBillRequests.ListQuery(), "scope", new MchCheckoutRelationScope(false, List.of(), "account")));
assertTrue(self.getSql().contains("m.merchant_create_by = ?"));
assertTrue(self.getParameterMappings().stream().anyMatch(parameter -> parameter.getProperty().equals("scope.username")));
}
/** 真实双行插入经同类租户拦截器重写后,两行均有租户值。 */
@Test
void everyClaimRowShouldReceiveTenantValue() throws Exception {
Configuration configuration = mapper("MchCheckoutOrderClaimMapper");
BoundSql bound = configuration.getMappedStatement("com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutOrderClaimMapper.insertRows")
.getBoundSql(Map.of("rows", List.of(new MchCheckoutOrderClaimEntity(), new MchCheckoutOrderClaimEntity())));
TenantLineInnerInterceptor interceptor = new TenantLineInnerInterceptor(new TenantLineHandler() {
/** 固定测试租户值,业务代码仍由项目上下文提供。 */
@Override
public Expression getTenantId() { return new LongValue(77); }
});
String rewritten = interceptor.parserSingle(bound.getSql(), null);
assertTrue(rewritten.contains("tenant_id"));
assertEquals(2, rewritten.split("\\b77\\b", -1).length - 1);
}
/** 从实际资源读取Mapper,验证动态片段和参数绑定,不连接数据库。 */
private Configuration mapper(String name) throws Exception {
Configuration configuration = new Configuration();
String resource = "mapper/mch/" + name + ".xml";
try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
assertNotNull(input);
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
}
return configuration;
}
}
@@ -0,0 +1,66 @@
package com.bsd.pjcloud.wallet.support.mch;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.supermarket.api.feign.RemoteSmHotelOrderCheckoutQueryService;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/** 远程事实读取回归,异常、漏单和退款不能当作可结账订单。 */
class MchCheckoutBillHotelSourceTest {
/** 批量响应遗漏任何一个明确主键都必须拒绝。 */
@Test
void missingResponseItemShouldFailRatherThanDisappear() {
RemoteSmHotelOrderCheckoutQueryService remote = mock(RemoteSmHotelOrderCheckoutQueryService.class);
when(remote.byIds(any())).thenReturn(R.ok(List.of(order(1L))));
MchCheckoutBillHotelSource source = new MchCheckoutBillHotelSource(remote, new ObjectMapper().findAndRegisterModules());
assertThrows(IllegalStateException.class, () -> source.fetch(List.of(1L, 2L)));
}
/** 渠道未知支付值保留人工核对,明确未付及退款状态拒绝。 */
@Test
void knownRefundAndUnpaidStatusesShouldBeBlocked() {
MchCheckoutBillHotelSource source = new MchCheckoutBillHotelSource(mock(RemoteSmHotelOrderCheckoutQueryService.class), new ObjectMapper().findAndRegisterModules());
SmHotelOrderCheckoutQueryVo row = order(1L);
row.setPayStatus("channel-custom");
assertEquals("", source.eligibility(row));
row.setPayStatus("REFUNDED");
assertFalse(source.eligibility(row).isEmpty());
row.setPayStatus("UNPAID");
assertFalse(source.eligibility(row).isEmpty());
}
/** 源事实摘要检测金额及归属变化,无关更新时间不使金额失效。 */
@Test
void fingerprintShouldFollowFinancialFacts() {
MchCheckoutBillHotelSource source = new MchCheckoutBillHotelSource(mock(RemoteSmHotelOrderCheckoutQueryService.class), new ObjectMapper().findAndRegisterModules());
SmHotelOrderCheckoutQueryVo row = order(1L);
String original = source.fingerprint(row);
row.setUpdateTime(LocalDateTime.now());
assertEquals(original, source.fingerprint(row));
row.setSellAmountYuan("99.00");
assertNotEquals(original, source.fingerprint(row));
row.setSellAmountYuan("100.00");
row.setDeptId(22L);
assertNotEquals(original, source.fingerprint(row));
}
/** 构造可进入人工核对池的最小酒店事实。 */
private SmHotelOrderCheckoutQueryVo order(Long id) {
SmHotelOrderCheckoutQueryVo row = new SmHotelOrderCheckoutQueryVo();
row.setOrderId(id);
row.setFound(true);
row.setResolutionStatus("OK");
row.setHotelId(10L);
row.setDeptId(11L);
row.setSellAmountYuan("100.00");
row.setOrderStatus("CHECKED_IN");
row.setPayStatus("PAID");
return row;
}
}
@@ -0,0 +1,42 @@
package com.bsd.pjcloud.wallet.support.mch;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** 酒店结账金额回归,覆盖固定每单抽成及精度边界。 */
class MchCheckoutBillMoneyTest {
/** 两笔100元订单固定各抽5元,应付总额严格为190元。 */
@Test
void twoOrdersShouldPayOneHundredNinetyYuan() {
long gross = MchCheckoutBillMoney.fen("100.00", "订单金额");
long fee = MchCheckoutBillMoney.fen("5.00", "每单抽成");
assertEquals(20000L, MchCheckoutBillMoney.add(gross, gross));
assertEquals(1000L, MchCheckoutBillMoney.add(fee, fee));
assertEquals(19000L, MchCheckoutBillMoney.add(MchCheckoutBillMoney.payable(gross, fee), MchCheckoutBillMoney.payable(gross, fee)));
}
/** 小数只能精确转分,禁止隐式四舍五入。 */
@Test
void decimalsShouldBeExact() {
assertEquals(10001L, MchCheckoutBillMoney.fen("100.0100", "订单金额"));
assertThrows(IllegalArgumentException.class, () -> MchCheckoutBillMoney.fen("100.005", "订单金额"));
assertThrows(IllegalArgumentException.class, () -> MchCheckoutBillMoney.fen("-1.00", "订单金额"));
assertThrows(IllegalArgumentException.class, () -> MchCheckoutBillMoney.fen(null, "订单金额"));
}
/** 零抽成和零应付均允许,零订单金额及超额抽成拒绝。 */
@Test
void zeroFeeAndZeroPayableShouldHaveExplicitBoundaries() {
assertEquals(10000L, MchCheckoutBillMoney.payable(10000L, 0L));
assertEquals(0L, MchCheckoutBillMoney.payable(500L, 500L));
assertThrows(IllegalArgumentException.class, () -> MchCheckoutBillMoney.payable(0L, 0L));
assertThrows(IllegalArgumentException.class, () -> MchCheckoutBillMoney.payable(499L, 500L));
}
/** 单笔和总额溢出都必须失败,防止金额绕回负数。 */
@Test
void overflowShouldFailTheWholeCalculation() {
assertThrows(IllegalArgumentException.class, () -> MchCheckoutBillMoney.fen("92233720368547758.08", "订单金额"));
assertThrows(IllegalArgumentException.class, () -> MchCheckoutBillMoney.add(Long.MAX_VALUE, 1L));
}
}