Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
d98f9fb4aa
40 files changed
+2270
-97
No files matched your search
@@ -7,55 +7,105 @@
|
||||
-- 其他业务租户仅需改租户值重复执行,钱包两项会自动跳过已存在的全局登记。
|
||||
-- 重复执行保留管理员已修改的周期、参数及暂停状态。
|
||||
|
||||
SET @job_tenant_id = 1;
|
||||
SET @job_id_base = (SELECT GREATEST(COALESCE(MAX(job_id), 0), 2026111000000000000) FROM sys_job);
|
||||
SET
|
||||
@job_tenant_id = 1;
|
||||
SET
|
||||
@job_id_base = (SELECT GREATEST(COALESCE(MAX(job_id), 0), 2026111000000000000) FROM sys_job);
|
||||
|
||||
INSERT INTO sys_job
|
||||
(job_id, job_name, job_group, job_order, job_type, execute_path, class_name, method_name,
|
||||
method_params_value, cron_expression, misfire_policy, job_tenant_type, job_status,
|
||||
job_execute_status, create_by, create_time, update_by, update_time, tenant_id, remark)
|
||||
SELECT @job_id_base + s.seq, s.job_name,
|
||||
SELECT @job_id_base + s.seq,
|
||||
s.job_name,
|
||||
IF(s.per_tenant = 1, CONCAT(s.group_prefix, '_T', @job_tenant_id), s.group_prefix),
|
||||
'1', '2', NULL, s.class_name, s.method_name, s.method_params_value, s.cron_expression,
|
||||
'3', IF(s.per_tenant = 1, '1', '2'), '2', '0',
|
||||
'system', NOW(), 'system', NOW(), @job_tenant_id, '20261110酒店结账及支付宝转账任务迁移'
|
||||
FROM (
|
||||
SELECT 1 AS seq, '酒店结账转账受理扫描' AS job_name, 'MCH_CHECKOUT' AS group_prefix,
|
||||
'mchCheckoutTask' AS class_name, 'dispatchTransfers' AS method_name,
|
||||
NULL AS method_params_value, '0/5 * * * * ?' AS cron_expression, 0 AS per_tenant
|
||||
UNION ALL
|
||||
SELECT 2, '酒店结账订单复核', 'MCH_CHECKOUT',
|
||||
'mchCheckoutTask', 'recheckOrders', NULL, '0 * * * * ?', 0
|
||||
UNION ALL
|
||||
SELECT 3, '支付宝转账提交', 'THIRD_TRANSFER',
|
||||
'thirdPaymentTask', 'dispatch', 'TRANSFER_SUBMIT,100', '0/5 * * * * ?', 1
|
||||
UNION ALL
|
||||
SELECT 4, '支付宝转账查单', 'THIRD_TRANSFER',
|
||||
'thirdPaymentTask', 'dispatch', 'TRANSFER_QUERY,100', '0/10 * * * * ?', 1
|
||||
UNION ALL
|
||||
SELECT 5, '转账业务结果通知', 'THIRD_TRANSFER',
|
||||
'thirdPaymentTask', 'dispatch', 'TRANSFER_NOTIFY,100', '0/10 * * * * ?', 1
|
||||
) s
|
||||
'1',
|
||||
'2',
|
||||
NULL,
|
||||
s.class_name,
|
||||
s.method_name,
|
||||
s.method_params_value,
|
||||
s.cron_expression,
|
||||
'3',
|
||||
IF(s.per_tenant = 1, '1', '2'),
|
||||
'2',
|
||||
'0',
|
||||
'system',
|
||||
NOW(),
|
||||
'system',
|
||||
NOW(),
|
||||
@job_tenant_id,
|
||||
'20261110酒店结账及支付宝转账任务迁移'
|
||||
FROM (SELECT 1 AS seq,
|
||||
'酒店结账转账受理扫描' AS job_name,
|
||||
'MCH_CHECKOUT' AS group_prefix,
|
||||
'mchCheckoutTask' AS class_name,
|
||||
'dispatchTransfers' AS method_name,
|
||||
NULL AS method_params_value,
|
||||
'0/5 * * * * ?' AS cron_expression,
|
||||
0 AS per_tenant
|
||||
UNION ALL
|
||||
SELECT 2,
|
||||
'酒店结账订单复核',
|
||||
'MCH_CHECKOUT',
|
||||
'mchCheckoutTask',
|
||||
'recheckOrders',
|
||||
NULL,
|
||||
'0 * * * * ?',
|
||||
0
|
||||
UNION ALL
|
||||
SELECT 3,
|
||||
'支付宝转账提交',
|
||||
'THIRD_TRANSFER',
|
||||
'thirdPaymentTask',
|
||||
'dispatch',
|
||||
'TRANSFER_SUBMIT,100',
|
||||
'0/5 * * * * ?',
|
||||
1
|
||||
UNION ALL
|
||||
SELECT 4,
|
||||
'支付宝转账查单',
|
||||
'THIRD_TRANSFER',
|
||||
'thirdPaymentTask',
|
||||
'dispatch',
|
||||
'TRANSFER_QUERY,100',
|
||||
'0/10 * * * * ?',
|
||||
1
|
||||
UNION ALL
|
||||
SELECT 5,
|
||||
'转账业务结果通知',
|
||||
'THIRD_TRANSFER',
|
||||
'thirdPaymentTask',
|
||||
'dispatch',
|
||||
'TRANSFER_NOTIFY,100',
|
||||
'0/10 * * * * ?',
|
||||
1) s
|
||||
WHERE @job_tenant_id > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_job j
|
||||
WHERE (s.per_tenant = 0 OR j.tenant_id = @job_tenant_id)
|
||||
AND (
|
||||
(BINARY j.job_name = BINARY s.job_name AND BINARY j.job_group = BINARY
|
||||
AND NOT EXISTS (SELECT 1
|
||||
FROM sys_job j
|
||||
WHERE (s.per_tenant = 0 OR j.tenant_id = @job_tenant_id)
|
||||
AND (
|
||||
(BINARY j.job_name = BINARY s.job_name AND BINARY j.job_group = BINARY
|
||||
IF(s.per_tenant = 1, CONCAT(s.group_prefix, '_T', @job_tenant_id), s.group_prefix))
|
||||
OR (BINARY j.class_name = BINARY s.class_name AND BINARY j.method_name = BINARY s.method_name
|
||||
OR (BINARY j.class_name = BINARY s.class_name AND BINARY j.method_name = BINARY s.method_name
|
||||
AND BINARY SUBSTRING_INDEX(COALESCE(j.method_params_value, ''), ',', 1) =
|
||||
BINARY SUBSTRING_INDEX(COALESCE(s.method_params_value, ''), ',', 1))
|
||||
)
|
||||
);
|
||||
));
|
||||
|
||||
SELECT ROW_COUNT() AS inserted_jobs;
|
||||
|
||||
SELECT job_id, job_name, job_group, class_name, method_name,
|
||||
method_params_value, cron_expression, job_status, tenant_id
|
||||
SELECT job_id,
|
||||
job_name,
|
||||
job_group,
|
||||
class_name,
|
||||
method_name,
|
||||
method_params_value,
|
||||
cron_expression,
|
||||
job_status,
|
||||
tenant_id
|
||||
FROM sys_job
|
||||
WHERE tenant_id = @job_tenant_id
|
||||
AND (class_name = 'mchCheckoutTask'
|
||||
OR (class_name = 'thirdPaymentTask'
|
||||
AND SUBSTRING_INDEX(method_params_value, ',', 1) IN ('TRANSFER_SUBMIT', 'TRANSFER_QUERY', 'TRANSFER_NOTIFY')))
|
||||
OR (class_name = 'thirdPaymentTask'
|
||||
AND SUBSTRING_INDEX(method_params_value, ',', 1) IN ('TRANSFER_SUBMIT', 'TRANSFER_QUERY', 'TRANSFER_NOTIFY')))
|
||||
ORDER BY job_group, job_id;
|
||||
@@ -0,0 +1,93 @@
|
||||
-- 商户用餐时间结构化迁移,MySQL 8.0+,在钱包业务库执行。
|
||||
-- 发布顺序:本脚本 -> 兼容后端 -> 后台管理端、UniApp。
|
||||
-- 迁移与后端发布之间需暂停商户营业时间写入,避免旧后端仅写 job_time 后产生新旧配置偏差。
|
||||
-- 新 JSON 按 daily_menu 编码保存多个时段;原 job_time 保留原值及 VARCHAR(256) 类型。
|
||||
-- 新后端将 job_time 作为旧客户端首时段投影,完整配置始终以 meal_time_config 为准。
|
||||
-- 同日等时刻表示用餐时间点;旧串结束早于开始时推断为次日结束。
|
||||
-- 仅回填新列为 SQL NULL 的记录,重复执行不覆盖已经保存的结构化配置,包括明确的空配置。
|
||||
-- 任一非空餐段格式异常时整行暂缓迁移,原串保留,脚本末尾列出异常位置和待处理商户。
|
||||
|
||||
SET @mch_meal_time_ddl = IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'mch_manage' AND column_name = 'meal_time_config'),
|
||||
'SELECT ''meal_time_config already exists'' AS migration_status',
|
||||
'ALTER TABLE mch_manage ADD COLUMN meal_time_config JSON NULL COMMENT ''按餐段编码保存的用餐时间,支持多时段、时间点及跨日'' AFTER job_time'
|
||||
);
|
||||
PREPARE mch_meal_time_stmt FROM @mch_meal_time_ddl;
|
||||
EXECUTE mch_meal_time_stmt;
|
||||
DEALLOCATE PREPARE mch_meal_time_stmt;
|
||||
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_mch_meal_time_source;
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_mch_meal_time_parts;
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_mch_meal_time_errors;
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_mch_meal_time_config;
|
||||
|
||||
-- 快照保留空位和原串;最终更新再次比较原串,防止覆盖迁移过程中发生的修改。
|
||||
CREATE TEMPORARY TABLE tmp_mch_meal_time_source AS
|
||||
SELECT id, job_time FROM mch_manage WHERE meal_time_config IS NULL;
|
||||
ALTER TABLE tmp_mch_meal_time_source ADD PRIMARY KEY (id);
|
||||
|
||||
-- JSON_QUOTE 处理旧值中的引号、反斜杠;FOR ORDINALITY 精确保留编码 10、11 等稀疏位置。
|
||||
CREATE TEMPORARY TABLE tmp_mch_meal_time_parts AS
|
||||
SELECT parsed.*,
|
||||
CASE WHEN raw_slot = '' THEN 1
|
||||
WHEN CHAR_LENGTH(raw_slot) - CHAR_LENGTH(REPLACE(raw_slot, '-', '')) = 1
|
||||
AND REGEXP_LIKE(start_time, '^([0-9]|[01][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$')
|
||||
AND REGEXP_LIKE(end_time, '^([0-9]|[01][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$')
|
||||
THEN 1 ELSE 0 END AS is_valid
|
||||
FROM (
|
||||
SELECT source.id, slots.slot_no, TRIM(slots.raw_slot) AS raw_slot,
|
||||
TRIM(SUBSTRING_INDEX(slots.raw_slot, '-', 1)) AS start_time,
|
||||
TRIM(SUBSTRING_INDEX(slots.raw_slot, '-', -1)) AS end_time
|
||||
FROM tmp_mch_meal_time_source source
|
||||
JOIN JSON_TABLE(
|
||||
CONCAT('[', REPLACE(JSON_QUOTE(COALESCE(source.job_time, '')), ',', '","'), ']'),
|
||||
'$[*]' COLUMNS (slot_no FOR ORDINALITY, raw_slot VARCHAR(256) PATH '$')
|
||||
) slots ON TRUE
|
||||
) parsed;
|
||||
ALTER TABLE tmp_mch_meal_time_parts ADD INDEX idx_mch_meal_time_parts_id (id);
|
||||
|
||||
CREATE TEMPORARY TABLE tmp_mch_meal_time_errors AS
|
||||
SELECT id, GROUP_CONCAT(slot_no ORDER BY slot_no) AS invalid_hour_types
|
||||
FROM tmp_mch_meal_time_parts WHERE is_valid = 0 GROUP BY id;
|
||||
ALTER TABLE tmp_mch_meal_time_errors ADD PRIMARY KEY (id);
|
||||
|
||||
-- 非零秒保留秒精度,零秒统一为 HH:mm;相同起止时刻 endDayOffset 为 0。
|
||||
CREATE TEMPORARY TABLE tmp_mch_meal_time_config AS
|
||||
SELECT id,
|
||||
JSON_OBJECT('periods', JSON_OBJECTAGG(CAST(slot_no AS CHAR), JSON_ARRAY(JSON_OBJECT(
|
||||
'startTime', TIME_FORMAT(start_value, IF(MOD(TIME_TO_SEC(start_value), 60) = 0, '%H:%i', '%H:%i:%s')),
|
||||
'endTime', TIME_FORMAT(end_value, IF(MOD(TIME_TO_SEC(end_value), 60) = 0, '%H:%i', '%H:%i:%s')),
|
||||
'endDayOffset', IF(end_value < start_value, 1, 0)
|
||||
)))) AS config_json
|
||||
FROM (
|
||||
SELECT id, slot_no, CAST(start_time AS TIME) AS start_value, CAST(end_time AS TIME) AS end_value
|
||||
FROM tmp_mch_meal_time_parts WHERE is_valid = 1 AND raw_slot <> ''
|
||||
) valid_parts
|
||||
GROUP BY id;
|
||||
ALTER TABLE tmp_mch_meal_time_config ADD PRIMARY KEY (id);
|
||||
|
||||
UPDATE mch_manage merchant
|
||||
JOIN tmp_mch_meal_time_source source ON source.id = merchant.id
|
||||
LEFT JOIN tmp_mch_meal_time_errors errors ON errors.id = merchant.id
|
||||
LEFT JOIN tmp_mch_meal_time_config config ON config.id = merchant.id
|
||||
SET merchant.meal_time_config = COALESCE(config.config_json, JSON_OBJECT('periods', JSON_OBJECT()))
|
||||
WHERE merchant.meal_time_config IS NULL
|
||||
AND errors.id IS NULL
|
||||
AND merchant.job_time <=> source.job_time;
|
||||
|
||||
SELECT ROW_COUNT() AS migrated_merchants;
|
||||
|
||||
-- 异常行不做猜测或部分迁移,修正原串后可重跑,也可由新版后台重新配置。
|
||||
SELECT source.id, source.job_time, errors.invalid_hour_types
|
||||
FROM tmp_mch_meal_time_errors errors
|
||||
JOIN tmp_mch_meal_time_source source ON source.id = errors.id
|
||||
ORDER BY source.id;
|
||||
|
||||
SELECT COUNT(*) AS configured_merchants FROM mch_manage WHERE meal_time_config IS NOT NULL;
|
||||
SELECT id, job_time FROM mch_manage WHERE meal_time_config IS NULL ORDER BY id;
|
||||
|
||||
DROP TEMPORARY TABLE tmp_mch_meal_time_config;
|
||||
DROP TEMPORARY TABLE tmp_mch_meal_time_errors;
|
||||
DROP TEMPORARY TABLE tmp_mch_meal_time_parts;
|
||||
DROP TEMPORARY TABLE tmp_mch_meal_time_source;
|
||||
+5
-4
@@ -24,6 +24,7 @@ import com.bsd.pjcloud.canteen.mapper.CanteenOnlineSpuMapper;
|
||||
import com.bsd.pjcloud.canteen.mapper.CanteenOrderingFoodMapper;
|
||||
import com.bsd.pjcloud.canteen.service.CanteenOrderingFoodService;
|
||||
import com.bsd.pjcloud.canteen.util.MchJobTimeMealSegmentUtils;
|
||||
import com.bsd.pjcloud.wallet.api.utils.MchMealTimeUtils;
|
||||
import com.bsd.pjcloud.canteen.util.MealSegmentCategoryUtils;
|
||||
import com.bsd.pjcloud.canteen.vo.app.OrderingFoodAppVO;
|
||||
import com.bsd.pjcloud.canteen.vo.app.OrderingFoodMealSegmentVO;
|
||||
@@ -93,7 +94,7 @@ public class CanteenOrderingFoodServiceImpl extends ServiceImpl<CanteenOrderingF
|
||||
if (!isOrderingFoodEnabled(mch)) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.ORDERING_FOOD_MCH_DISABLED));
|
||||
}
|
||||
List<OrderingFoodMealSegmentVO> segments = MchJobTimeMealSegmentUtils.parseMealSegments(mch.getJobTime());
|
||||
List<OrderingFoodMealSegmentVO> segments = MchJobTimeMealSegmentUtils.parseMealSegments(MchMealTimeUtils.toReportingIntervals(mch));
|
||||
if (CollUtil.isEmpty(segments)) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.ORDERING_FOOD_JOB_TIME_MISSING_FOR_SEGMENT));
|
||||
}
|
||||
@@ -133,7 +134,7 @@ public class CanteenOrderingFoodServiceImpl extends ServiceImpl<CanteenOrderingF
|
||||
if (dto.getPeopleCount() == null || dto.getPeopleCount() < 1) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.ORDERING_FOOD_FILL_PERSON_COUNT));
|
||||
}
|
||||
String jobTime = mch.getJobTime();
|
||||
String jobTime = MchMealTimeUtils.toReportingIntervals(mch);
|
||||
if (MchJobTimeMealSegmentUtils.isBlankJobTime(jobTime)) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.ORDERING_FOOD_JOB_TIME_MISSING));
|
||||
}
|
||||
@@ -523,7 +524,7 @@ public class CanteenOrderingFoodServiceImpl extends ServiceImpl<CanteenOrderingF
|
||||
Long deptId, Long userId) {
|
||||
MchManageVo mch = loadMerchantOrFail(merchantId);
|
||||
List<OrderingFoodMealSegmentVO> segments = mch != null
|
||||
? MchJobTimeMealSegmentUtils.parseMealSegments(mch.getJobTime())
|
||||
? MchJobTimeMealSegmentUtils.parseMealSegments(MchMealTimeUtils.toReportingIntervals(mch))
|
||||
: List.of();
|
||||
var q = Wrappers.<CanteenOrderingFoodEntity>lambdaQuery()
|
||||
.eq(CanteenOrderingFoodEntity::getMerchantId, merchantId)
|
||||
@@ -572,7 +573,7 @@ public class CanteenOrderingFoodServiceImpl extends ServiceImpl<CanteenOrderingF
|
||||
Long deptId, Long userId, Integer segmentIndex) {
|
||||
MchManageVo mch = loadMerchantOrFail(merchantId);
|
||||
List<OrderingFoodMealSegmentVO> segmentDefs = mch != null
|
||||
? MchJobTimeMealSegmentUtils.parseMealSegments(mch.getJobTime())
|
||||
? MchJobTimeMealSegmentUtils.parseMealSegments(MchMealTimeUtils.toReportingIntervals(mch))
|
||||
: List.of();
|
||||
if (CollUtil.isEmpty(segmentDefs)) {
|
||||
OrderingFoodStatsDayDetailVO empty = new OrderingFoodStatsDayDetailVO();
|
||||
|
||||
+4
@@ -16,6 +16,10 @@ public enum HardHourTypeEnum {
|
||||
NOONING("2", "午餐"),
|
||||
NIGHT("3", "晚餐"),
|
||||
LATE_NIGHT("4", "夜间餐"),
|
||||
|
||||
|
||||
REQUIRED("10", "必选餐"),
|
||||
RECESS_SNACK("11", "课间餐"),
|
||||
;
|
||||
private final String type;
|
||||
private final String description;
|
||||
|
||||
+32
@@ -1,8 +1,13 @@
|
||||
package com.bsd.pjcloud.hard.service;
|
||||
|
||||
import com.bsd.pjcloud.hard.api.constant.HardApptReserveModeEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum;
|
||||
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 统一解析商户当前生效的订餐预约模式。
|
||||
*/
|
||||
@@ -16,6 +21,33 @@ public interface HardApptMerchantModeService {
|
||||
*/
|
||||
MerchantModeContext resolve(Long mchId);
|
||||
|
||||
/**
|
||||
* 一次远程读取的商户预约上下文,列表、预览、规则保存与下单共用同一餐段范围。
|
||||
*
|
||||
* @param merchant 当前租户内的商户详情
|
||||
* @param reserveMode 当前生效的预约时间模式
|
||||
*/
|
||||
record MerchantModeContext(MchManageVo merchant, HardApptReserveModeEnum reserveMode) {
|
||||
|
||||
/** 获取按配置顺序排列的餐段编码;历史缺失配置沿用全部既有餐段。 */
|
||||
public List<String> hourTypes() {
|
||||
var conf = merchant.getTableReservationConf();
|
||||
if (conf == null || conf.getHourTypes() == null) {
|
||||
return Arrays.stream(HardHourTypeEnum.values()).map(HardHourTypeEnum::getType).toList();
|
||||
}
|
||||
return conf.getHourTypes().stream().filter(code -> HardHourTypeEnum.valueOfByType(code) != null).distinct().toList();
|
||||
}
|
||||
|
||||
/** 显式关闭定餐时不开放预约;历史缺失开关继续沿用既有行为。 */
|
||||
public boolean reservationEnabled() {
|
||||
return merchant.getFunctionScope() == null || !Boolean.FALSE.equals(merchant.getFunctionScope().getTableReservation());
|
||||
}
|
||||
|
||||
/** 保存规则明细前校验商户餐段范围;不修改已保存的历史规则。 */
|
||||
public void validateHourTypes(Collection<String> requested) {
|
||||
if (!hourTypes().containsAll(requested)) {
|
||||
throw new RuntimeException("预约规则包含商户未启用的订餐时间段,请重新选择");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -154,6 +154,7 @@ public class HardApptCycleRuleServiceImpl
|
||||
if (merchantContext.reserveMode() != HardApptReserveModeEnum.WEEKLY_CYCLE) {
|
||||
throw new RuntimeException("当前商户使用动态时间窗,不能维护周期预约规则");
|
||||
}
|
||||
merchantContext.validateHourTypes(items.stream().map(HardApptCycleRuleItemDto::getHourType).toList());
|
||||
dto.setDeptId(merchantContext.merchant().getDeptId());
|
||||
|
||||
if (YesNoEnum.YES.getCode().equals(dto.getStatus())) {
|
||||
|
||||
+22
-8
@@ -38,6 +38,7 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -71,7 +72,9 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
|
||||
|
||||
/** 按固定北京时间生成 App 列表,保留当前开放及未来将开放的目标,便于核对截止边界。 */
|
||||
List<HardApptDateOptionsVo> listOptions(Long mchId, LocalDateTime now) {
|
||||
HardApptReserveModeEnum merchantMode = resolveMerchantMode(mchId);
|
||||
HardApptMerchantModeService.MerchantModeContext merchantContext = merchantModeService.resolve(mchId);
|
||||
HardApptReserveModeEnum merchantMode = merchantContext.reserveMode();
|
||||
if (!merchantContext.reservationEnabled()) return List.of();
|
||||
LocalDate today = now.toLocalDate();
|
||||
Map<LocalDate, LinkedHashMap<String, HardApptOptionVo>> optionsByDate = new TreeMap<>();
|
||||
|
||||
@@ -107,9 +110,9 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
|
||||
return optionsByDate.entrySet().stream().map(entry -> {
|
||||
HardApptDateOptionsVo dateOptions = new HardApptDateOptionsVo();
|
||||
dateOptions.setApptDate(entry.getKey());
|
||||
dateOptions.setApptList(new ArrayList<>(entry.getValue().values()));
|
||||
dateOptions.setApptList(filterMerchantOptions(new ArrayList<>(entry.getValue().values()), merchantContext));
|
||||
return dateOptions;
|
||||
}).toList();
|
||||
}).filter(day -> !day.getApptList().isEmpty()).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,7 +124,8 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
|
||||
}
|
||||
|
||||
List<HardApptDateOptionsVo> previewOptions(Long mchId, Integer days, LocalDateTime now) {
|
||||
HardApptReserveModeEnum merchantMode = resolveMerchantMode(mchId);
|
||||
HardApptMerchantModeService.MerchantModeContext merchantContext = merchantModeService.resolve(mchId);
|
||||
HardApptReserveModeEnum merchantMode = merchantContext.reserveMode();
|
||||
int previewDays = days == null ? DEFAULT_PREVIEW_DAYS : days;
|
||||
if (previewDays < 1 || previewDays > MAX_PREVIEW_DAYS) {
|
||||
throw new RuntimeException("推演天数必须在1至31天之间");
|
||||
@@ -142,7 +146,7 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
|
||||
return optionsByDate.entrySet().stream().map(entry -> {
|
||||
HardApptDateOptionsVo dateOptions = new HardApptDateOptionsVo();
|
||||
dateOptions.setApptDate(entry.getKey());
|
||||
dateOptions.setApptList(new ArrayList<>(entry.getValue().values()));
|
||||
dateOptions.setApptList(filterMerchantOptions(new ArrayList<>(entry.getValue().values()), merchantContext));
|
||||
return dateOptions;
|
||||
}).toList();
|
||||
}
|
||||
@@ -261,7 +265,13 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
|
||||
/** 固定当前时刻校验目标和窗口归属,并返回当前代表窗口及不可由客户端覆盖的最终退款截止。 */
|
||||
ReservationValidation validateReservation(Long mchId, Long apptId, Long apptRuleId, String hourType,
|
||||
LocalDate apptDate, LocalDateTime now) {
|
||||
HardApptReserveModeEnum merchantMode = resolveMerchantMode(mchId);
|
||||
HardApptMerchantModeService.MerchantModeContext merchantContext = merchantModeService.resolve(mchId);
|
||||
HardApptReserveModeEnum merchantMode = merchantContext.reserveMode();
|
||||
// 服务端读取最新商户配置,阻止停用餐段的旧页面或旧购物车继续生成订单。
|
||||
if (!merchantContext.reservationEnabled()
|
||||
|| HardHourTypeEnum.valueOfByType(hourType) != null && !merchantContext.hourTypes().contains(hourType)) {
|
||||
throw new I18nBizException(ErrorCodes.HARD_APPT_SLOT_DISABLED);
|
||||
}
|
||||
if (merchantMode == HardApptReserveModeEnum.WEEKLY_CYCLE) {
|
||||
if (apptRuleId == null) {
|
||||
throw new I18nBizException(ErrorCodes.HARD_APPT_CYCLE_RULE_REQUIRED);
|
||||
@@ -407,8 +417,12 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
|
||||
weekdayLabel(rule.getOpenWeekday()), rule.getOpenTime().format(TIME_FORMATTER));
|
||||
}
|
||||
|
||||
private HardApptReserveModeEnum resolveMerchantMode(Long mchId) {
|
||||
return merchantModeService.resolve(mchId).reserveMode();
|
||||
/** 列表与实际效果预览使用同一商户餐段范围和保存顺序,关闭定餐时返回空选项。 */
|
||||
private List<HardApptOptionVo> filterMerchantOptions(List<HardApptOptionVo> options, HardApptMerchantModeService.MerchantModeContext context) {
|
||||
if (!context.reservationEnabled()) return List.of();
|
||||
List<String> hourTypes = context.hourTypes();
|
||||
return options.stream().filter(option -> hourTypes.contains(option.getHourType()))
|
||||
.sorted(Comparator.comparingInt(option -> hourTypes.indexOf(option.getHourType()))).toList();
|
||||
}
|
||||
|
||||
private static LocalDateTime nowInShanghai() {
|
||||
|
||||
+16
-3
@@ -78,6 +78,7 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
|
||||
public Long create(HardApptDto dto) {
|
||||
HardApptMerchantModeService.MerchantModeContext merchantContext = requireTimeWindowMerchant(dto.getMchId());
|
||||
List<HardApptWindowDto> windows = validateAndNormalizeWindows(dto);
|
||||
validateMerchantMeals(windows, merchantContext);
|
||||
dto.setStatus(ObjectUtil.defaultIfEmpty(dto.getStatus(), YesNoEnum.YES.getCode()));
|
||||
dto.setDeptId(merchantContext.merchant().getDeptId());
|
||||
baseMapper.insert(dto);
|
||||
@@ -102,6 +103,7 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
|
||||
if (ObjectUtil.isNotEmpty(dto.getWindowList())) {
|
||||
HardApptMerchantModeService.MerchantModeContext merchantContext = requireTimeWindowMerchant(existing.getMchId());
|
||||
windows = validateAndNormalizeWindows(dto);
|
||||
validateMerchantMeals(windows, merchantContext);
|
||||
dto.setDeptId(merchantContext.merchant().getDeptId());
|
||||
}
|
||||
baseMapper.updateById(dto);
|
||||
@@ -142,8 +144,9 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
|
||||
if (rule == null) {
|
||||
throw new RuntimeException("预约规则不存在");
|
||||
}
|
||||
requireTimeWindowMerchant(rule.getMchId());
|
||||
var merchantContext = requireTimeWindowMerchant(rule.getMchId());
|
||||
normalizeWindow(window, 1);
|
||||
validateMerchantMeals(List.of(window), merchantContext);
|
||||
HardApptWindowEntity update = new HardApptWindowEntity();
|
||||
update.setId(window.getId());
|
||||
update.setStartTime(window.getStartTime());
|
||||
@@ -332,12 +335,13 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
|
||||
|| scoped.getData().stream().noneMatch(merchant -> Objects.equals(merchant.getId(), dto.getMchId()))) {
|
||||
throw new RuntimeException("无权预览当前商户或商户数据权限查询失败");
|
||||
}
|
||||
requireTimeWindowMerchant(dto.getMchId());
|
||||
var merchantContext = requireTimeWindowMerchant(dto.getMchId());
|
||||
if (dto.getId() != null && baseMapper.selectListByScope(new LambdaQueryWrapper<HardApptEntity>()
|
||||
.eq(HardApptEntity::getId, dto.getId()).eq(HardApptEntity::getMchId, dto.getMchId()), dataScope).isEmpty()) {
|
||||
throw new RuntimeException("预约规则不存在或不在当前数据权限范围内");
|
||||
}
|
||||
List<HardApptWindowDto> draft = validateAndNormalizeWindows(dto);
|
||||
validateMerchantMeals(draft, merchantContext);
|
||||
List<WindowSource> sources = new ArrayList<>(listEnabledWindows(dto.getMchId()).stream()
|
||||
.filter(source -> !Objects.equals(source.window().getApptId(), dto.getId())).toList());
|
||||
Map<LocalDate, List<HardApptOptionVo>> dates = new java.util.TreeMap<>();
|
||||
@@ -362,7 +366,10 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
|
||||
targets.stream().distinct().sorted(java.util.Comparator.comparing(Target::date).thenComparing(Target::hourType))
|
||||
.forEach(target -> {
|
||||
ResolvedTarget value = resolved.get(target);
|
||||
if (value != null) dates.get(target.date()).add(HardApptWindowResolver.toOption(target, value, now));
|
||||
if (value != null && merchantContext.reservationEnabled()
|
||||
&& merchantContext.hourTypes().contains(target.hourType())) {
|
||||
dates.get(target.date()).add(HardApptWindowResolver.toOption(target, value, now));
|
||||
}
|
||||
});
|
||||
return dates.entrySet().stream().map(entry -> {
|
||||
HardApptDateOptionsVo day = new HardApptDateOptionsVo();
|
||||
@@ -456,6 +463,12 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存或推演动态明细前校验餐段范围,商户配置停用餐段时保留数据库中的旧规则。 */
|
||||
private void validateMerchantMeals(List<HardApptWindowDto> windows, HardApptMerchantModeService.MerchantModeContext context) {
|
||||
context.validateHourTypes(windows.stream().flatMap(window -> window.getItemList().stream())
|
||||
.map(HardApptItemDto::getHourType).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 餐段规则只在商户当前选择动态模式时允许维护,切换模式不会删除原有数据。
|
||||
*/
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@ import com.bsd.pjcloud.hard.service.HardTakeOutDeliveryTimeOptionService;
|
||||
import com.bsd.pjcloud.hard.util.MchJobTimeUtils;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
|
||||
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
|
||||
import com.bsd.pjcloud.wallet.api.utils.MchMealTimeUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -50,7 +51,7 @@ public class HardTakeOutDeliveryTimeOptionServiceImpl implements HardTakeOutDeli
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.COMMON_MCH_NOT_FOUND));
|
||||
}
|
||||
MchManageVo mch = r.getData();
|
||||
String jobTime = mch.getJobTime();
|
||||
String jobTime = MchMealTimeUtils.toDailyIntervals(mch);
|
||||
|
||||
HardTakeOutDeliveryTimeOptionVO vo = new HardTakeOutDeliveryTimeOptionVO();
|
||||
vo.setMerchantId(merchantId);
|
||||
|
||||
+8
-6
@@ -104,6 +104,7 @@ import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateShopOrderVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo;
|
||||
import com.bsd.pjcloud.hard.util.MchJobTimeUtils;
|
||||
import com.bsd.pjcloud.wallet.api.utils.MchMealTimeUtils;
|
||||
import com.bsd.pjcloud.hard.mapper.HardApptWindowMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardMessageOutboxMapper;
|
||||
@@ -5936,19 +5937,20 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
}
|
||||
|
||||
/**
|
||||
* 预约/立即送时间须落在商户 {@link com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity#getJobTime()} 营业时段内(如 {@code 09:00-12:00,16:00-19:00})。
|
||||
* 预约/立即送时间须落在商户已启用餐段的完整营业时间内,兼容旧字段及每日跨日时间。
|
||||
*/
|
||||
private void validateTakeOutApptAgainstMerchantJobTime(MchManageVo merchant, HardAppTakeOutApptDto appt) {
|
||||
if (appt == null || merchant == null) {
|
||||
return;
|
||||
}
|
||||
String jobTime = MchMealTimeUtils.toDailyIntervals(merchant);
|
||||
String type = StrUtil.emptyToDefault(appt.getExpectDeliveryType(), "IMMEDIATE").trim();
|
||||
if ("IMMEDIATE".equalsIgnoreCase(type)) {
|
||||
if (appt.getExpectArriveTime() != null) {
|
||||
if (MchJobTimeUtils.isBlankJobTime(merchant.getJobTime())) {
|
||||
if (MchJobTimeUtils.isBlankJobTime(jobTime)) {
|
||||
throw new I18nBizException(ErrorCodes.HARD_MCH_JOB_TIME_MISSING);
|
||||
}
|
||||
if (!MchJobTimeUtils.isTimeWithinJobTime(merchant.getJobTime(), appt.getExpectArriveTime().toLocalTime())) {
|
||||
if (!MchJobTimeUtils.isTimeWithinJobTime(jobTime, appt.getExpectArriveTime().toLocalTime())) {
|
||||
throw new I18nBizException(ErrorCodes.HARD_ETA_OUT_OF_HOURS);
|
||||
}
|
||||
}
|
||||
@@ -5957,13 +5959,13 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
if (!"SCHEDULED".equalsIgnoreCase(type)) {
|
||||
return;
|
||||
}
|
||||
if (MchJobTimeUtils.isBlankJobTime(merchant.getJobTime())) {
|
||||
if (MchJobTimeUtils.isBlankJobTime(jobTime)) {
|
||||
throw new I18nBizException(ErrorCodes.HARD_NO_SCHEDULE_WITHOUT_HOURS);
|
||||
}
|
||||
if (MchJobTimeUtils.mergeJobTimeIntervals(merchant.getJobTime()).isEmpty()) {
|
||||
if (MchJobTimeUtils.mergeJobTimeIntervals(jobTime).isEmpty()) {
|
||||
throw new I18nBizException(ErrorCodes.HARD_JOB_TIME_FORMAT_BAD);
|
||||
}
|
||||
if (!MchJobTimeUtils.isDeliverySlotWithinJobTime(merchant.getJobTime(), appt.getExpectDeliveryStart(), appt.getExpectDeliveryEnd())) {
|
||||
if (!MchJobTimeUtils.isDeliverySlotWithinJobTime(jobTime, appt.getExpectDeliveryStart(), appt.getExpectDeliveryEnd())) {
|
||||
throw new I18nBizException(ErrorCodes.HARD_SLOT_OUT_OF_HOURS);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-9
@@ -15,6 +15,7 @@ import java.util.List;
|
||||
*/
|
||||
public final class MchJobTimeUtils {
|
||||
|
||||
/** 当天分钟精度时间格式,用于兼容旧营业时间输入。 */
|
||||
private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("HH:mm");
|
||||
/** 兼容 jobTime 配置与前端 LocalTime 序列化(可带秒) */
|
||||
private static final DateTimeFormatter HM_OPTIONAL_SEC = new DateTimeFormatterBuilder()
|
||||
@@ -24,9 +25,11 @@ public final class MchJobTimeUtils {
|
||||
.optionalEnd()
|
||||
.toFormatter();
|
||||
|
||||
/** 工具类不承载商户状态。 */
|
||||
private MchJobTimeUtils() {
|
||||
}
|
||||
|
||||
/** 空配置无法提供配送时间约束,由调用入口反馈未配置状态。 */
|
||||
public static boolean isBlankJobTime(String jobTime) {
|
||||
return StrUtil.isBlank(StrUtil.trim(jobTime));
|
||||
}
|
||||
@@ -91,7 +94,8 @@ public final class MchJobTimeUtils {
|
||||
if (a == null || b == null) {
|
||||
continue;
|
||||
}
|
||||
if (!b.isAfter(a)) {
|
||||
// 等时刻是用餐时间点,可匹配立即送,切分预约格子时不会产生正时长格子。
|
||||
if (b.isBefore(a)) {
|
||||
continue;
|
||||
}
|
||||
raw.add(new TimeRange(a, b));
|
||||
@@ -130,19 +134,16 @@ public final class MchJobTimeUtils {
|
||||
List<TimeRange> merged = mergeJobTimeIntervals(jobTime);
|
||||
List<TimeRange> out = new ArrayList<>();
|
||||
for (TimeRange r : merged) {
|
||||
LocalTime cursor = r.start;
|
||||
while (true) {
|
||||
LocalTime slotEnd = cursor.plusMinutes(slotMinutes);
|
||||
if (slotEnd.isAfter(r.end)) {
|
||||
break;
|
||||
}
|
||||
out.add(new TimeRange(cursor, slotEnd));
|
||||
cursor = slotEnd;
|
||||
// 使用有界秒数,避免 LocalTime.plusMinutes 跨午夜回绕导致无限切分。
|
||||
long step = (long) slotMinutes * 60;
|
||||
for (long cursor = r.start.toSecondOfDay(); cursor + step <= r.end.toSecondOfDay(); cursor += step) {
|
||||
out.add(new TimeRange(LocalTime.ofSecondOfDay(cursor), LocalTime.ofSecondOfDay(cursor + step)));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 兼容分钟和秒精度的当天时刻,非法旧值返回 null 并由解析入口跳过。 */
|
||||
private static LocalTime parseHm(String s) {
|
||||
try {
|
||||
return LocalTime.parse(s, HM_OPTIONAL_SEC);
|
||||
@@ -155,10 +156,14 @@ public final class MchJobTimeUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/** 既有配送协议中的当天闭区间,结束时刻不早于开始时刻。 */
|
||||
public static final class TimeRange {
|
||||
/** 当天区间开始时刻,不含日期。 */
|
||||
public final LocalTime start;
|
||||
/** 当天区间结束时刻,等于开始时刻时表示时间点。 */
|
||||
public final LocalTime end;
|
||||
|
||||
/** 创建解析或切分后的当天时段,边界由调用方保证有效。 */
|
||||
public TimeRange(LocalTime start, LocalTime end) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
|
||||
+10
@@ -199,6 +199,16 @@ public interface RemoteUserService {
|
||||
@PostMapping("/user/user-dept")
|
||||
R<List<UserVO>> getUserDeptByIds(@RequestBody List<Long> userIds);
|
||||
|
||||
/**
|
||||
* 在原用户批量查询范围内按需返回身份扩展资料,供关系用户列表展示学生字段。
|
||||
*
|
||||
* @param userIds 已由业务确定的目标用户 ID 列表
|
||||
* @param includeIdentityInfo 是否补齐学生、老师资料;旧调用保持只查询身份编码
|
||||
* @return 用户与身份资料;调用方必须检查业务状态和空数据
|
||||
*/
|
||||
@PostMapping("/user/user-dept")
|
||||
R<List<UserVO>> getUserDeptByIds(@RequestBody List<Long> userIds, @RequestParam("includeIdentityInfo") boolean includeIdentityInfo);
|
||||
|
||||
/**
|
||||
* 通过社交账号或手机号查询用户、角色信息
|
||||
*
|
||||
|
||||
+12
@@ -1,5 +1,8 @@
|
||||
package com.bsd.pjcloud.admin.api.vo.user;
|
||||
|
||||
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
|
||||
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -38,4 +41,13 @@ public class SysUserGuardianWardOptionVo implements Serializable {
|
||||
*/
|
||||
@Schema(description = "被监护人的用户身份协议编码")
|
||||
private List<String> identityTypes;
|
||||
|
||||
/** 学生的学校、学号、班级和监护人手机号;非学生或尚未维护时为空。 */
|
||||
@Schema(description = "被监护人的学生身份资料")
|
||||
@JsonIgnoreProperties("extJson")
|
||||
private SysUserStudentDto studentInfo;
|
||||
|
||||
/** 老师学号资料;无老师身份或尚未维护时为空。 */
|
||||
@Schema(description = "被监护人的老师身份资料")
|
||||
private SysUserTeacherDto teacherInfo;
|
||||
}
|
||||
+11
@@ -1,5 +1,7 @@
|
||||
package com.bsd.pjcloud.admin.api.vo.user;
|
||||
|
||||
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -50,6 +52,15 @@ public class SysUserRelationGraphVo implements Serializable {
|
||||
@Schema(description = "性别:0未知,1男,2女")
|
||||
private Integer gender;
|
||||
|
||||
/** 用户真实身份协议编码,学生资料显示依据,不依赖关系中的角色名称。 */
|
||||
@Schema(description = "用户身份协议编码")
|
||||
private List<String> identityTypes;
|
||||
|
||||
/** 学生身份资料,非学生或未维护时为空;图谱不返回非核心扩展 JSON。 */
|
||||
@Schema(description = "学生的学校、学号、班级和监护人手机号")
|
||||
@JsonIgnoreProperties("extJson")
|
||||
private SysUserStudentDto studentInfo;
|
||||
|
||||
@Schema(description = "图谱中的用户数量,仅根节点返回,重复用户不重复计数")
|
||||
private Integer userCount;
|
||||
|
||||
|
||||
+4
-3
@@ -828,14 +828,15 @@ public class SysUserController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按用户 ID 批量查询用户、部门及身份协议编码。
|
||||
* 按用户 ID 批量查询用户、部门及身份协议编码,可按需返回身份扩展资料。
|
||||
*
|
||||
* @param userIds 待查询用户 ID 列表
|
||||
* @param includeIdentityInfo 是否补齐学生、老师资料;省略时保持旧接口行为
|
||||
* @return 用户、部门和身份资料;不存在的用户不返回记录
|
||||
*/
|
||||
@PostMapping("/user-dept")
|
||||
public R<List<UserVO>> getUserDeptByIds(@RequestBody List<Long> userIds) {
|
||||
return R.ok(userService.getUserDeptByIds(userIds));
|
||||
public R<List<UserVO>> getUserDeptByIds(@RequestBody List<Long> userIds, @RequestParam(value = "includeIdentityInfo", defaultValue = "false") boolean includeIdentityInfo) {
|
||||
return R.ok(userService.getUserDeptByIds(userIds, includeIdentityInfo));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+9
@@ -256,6 +256,15 @@ public interface SysUserService extends IService<SysUser> {
|
||||
*/
|
||||
List<UserVO> getUserDeptByIds(List<Long> userIds);
|
||||
|
||||
/**
|
||||
* 按既有批量查询范围读取用户,可选补齐身份扩展资料,不应用操作者数据权限筛选。
|
||||
*
|
||||
* @param userIds 由调用业务确定的用户 ID,租户隔离由既有数据组件处理
|
||||
* @param includeIdentityInfo 是否批量装配学生、老师资料;为 false 时只返回身份编码
|
||||
* @return 对应的用户资料;空 ID 列表返回空列表,不存在的用户不返回记录
|
||||
*/
|
||||
List<UserVO> getUserDeptByIds(List<Long> userIds, boolean includeIdentityInfo);
|
||||
|
||||
/**
|
||||
* 查询所有用户简单信息
|
||||
*/
|
||||
|
||||
+21
-2
@@ -1329,9 +1329,28 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
|
||||
*/
|
||||
@Override
|
||||
public List<UserVO> getUserDeptByIds(List<Long> userIds) {
|
||||
return getUserDeptByIds(userIds, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按需批量装配身份资料,保持原查询范围,避免普通身份校验额外读取学生和老师扩展表。
|
||||
*
|
||||
* @param userIds 业务已确定的用户 ID 列表;为空时不查询
|
||||
* @param includeIdentityInfo 是否返回身份扩展资料
|
||||
* @return 已补齐身份编码及所请求扩展资料的用户列表
|
||||
*/
|
||||
@Override
|
||||
public List<UserVO> getUserDeptByIds(List<Long> userIds, boolean includeIdentityInfo) {
|
||||
if (CollUtil.isEmpty(userIds)) {
|
||||
return List.of();
|
||||
}
|
||||
List<UserVO> users = baseMapper.selectVoListByScope(new UserDTO(), userIds.toArray(new Long[0]), null);
|
||||
// 跨服务订餐资格校验复用该批量接口,必须返回身份协议编码,避免各业务服务直接查询 UPMS 身份表。
|
||||
sysUserIdentityService.fillUserIdentityTypes(users);
|
||||
if (includeIdentityInfo) {
|
||||
sysUserIdentityService.fillUserIdentities(users);
|
||||
} else {
|
||||
// 普通订餐、充值资格校验仍只读取身份编码,保持既有调用成本。
|
||||
sysUserIdentityService.fillUserIdentityTypes(users);
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
|
||||
+32
-3
@@ -22,6 +22,7 @@ import com.bsd.pjcloud.admin.service.SysUserRelationService;
|
||||
import com.bsd.pjcloud.admin.service.SysUserRelationTypeService;
|
||||
import com.bsd.pjcloud.admin.service.SysUserService;
|
||||
import com.bsd.pjcloud.admin.service.SysUserStudentService;
|
||||
import com.bsd.pjcloud.admin.service.SysUserIdentityService;
|
||||
import com.bsd.pjcloud.common.core.constant.CommonConstants;
|
||||
import com.bsd.pjcloud.common.core.exception.ErrorCodes;
|
||||
import com.bsd.pjcloud.common.core.util.MsgUtils;
|
||||
@@ -68,6 +69,9 @@ public class SysUserRelationServiceImpl extends ServiceImpl<SysUserRelationMappe
|
||||
/** 学生扩展信息服务,用于维护由监护关系派生的手机号集合。 */
|
||||
private final SysUserStudentService sysUserStudentService;
|
||||
|
||||
/** 统一身份装配服务,批量读取图谱用户资料,避免每个节点单独查询。 */
|
||||
private final SysUserIdentityService sysUserIdentityService;
|
||||
|
||||
/**
|
||||
* 按关系对象反向查询消息接收人,并在事件时间和处理时间分别校验有效期。
|
||||
*
|
||||
@@ -210,6 +214,7 @@ public class SysUserRelationServiceImpl extends ServiceImpl<SysUserRelationMappe
|
||||
Map<Long, SysUserRelationGraphVo> nodeMap = graphNodes.stream()
|
||||
.map(node -> buildGraphNode(node, userMap, relationTypeMap))
|
||||
.collect(Collectors.toMap(SysUserRelationGraphVo::getNodeId, Function.identity()));
|
||||
fillGraphUserIdentities(new ArrayList<>(nodeMap.values()));
|
||||
SysUserRelationGraphVo rootNode = nodeMap.get(TREE_ROOT_NODE_ID);
|
||||
graphNodes.stream()
|
||||
.filter(node -> !Objects.equals(node.nodeId, TREE_ROOT_NODE_ID))
|
||||
@@ -289,7 +294,7 @@ public class SysUserRelationServiceImpl extends ServiceImpl<SysUserRelationMappe
|
||||
if (wardUserIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<UserVO> wardUsers = sysUserService.getUserDeptByIds(wardUserIds);
|
||||
List<UserVO> wardUsers = sysUserService.getUserDeptByIds(wardUserIds, true);
|
||||
if (CollUtil.isEmpty(wardUsers)) {
|
||||
return List.of();
|
||||
}
|
||||
@@ -409,9 +414,9 @@ public class SysUserRelationServiceImpl extends ServiceImpl<SysUserRelationMappe
|
||||
}
|
||||
|
||||
/**
|
||||
* 将用户转换为不包含敏感字段的被监护人选择项。
|
||||
* 将用户转换为包含订餐展示所需身份资料的被监护人选择项。
|
||||
*
|
||||
* @param wardUser 已补齐身份协议编码的被监护人用户
|
||||
* @param wardUser 已批量补齐身份编码及扩展资料的被监护人用户
|
||||
* @return 被监护人选择项
|
||||
*/
|
||||
private SysUserGuardianWardOptionVo buildGuardianWardOption(UserVO wardUser) {
|
||||
@@ -420,6 +425,8 @@ public class SysUserRelationServiceImpl extends ServiceImpl<SysUserRelationMappe
|
||||
option.setName(StrUtil.blankToDefault(wardUser.getName(), "被监护人"));
|
||||
option.setAvatar(wardUser.getAvatar());
|
||||
option.setIdentityTypes(wardUser.getIdentityTypes());
|
||||
option.setStudentInfo(wardUser.getStudentInfo());
|
||||
option.setTeacherInfo(wardUser.getTeacherInfo());
|
||||
return option;
|
||||
}
|
||||
|
||||
@@ -824,6 +831,28 @@ public class SysUserRelationServiceImpl extends ServiceImpl<SysUserRelationMappe
|
||||
.in(SysUserRelationEntity::getObjectUserId, userIds)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按用户去重后复用统一身份装配服务,根节点和重复引用节点均回填同一份学生资料。
|
||||
*
|
||||
* @param nodes 已按原关系范围构建的图谱节点,不扩展可见用户范围
|
||||
*/
|
||||
private void fillGraphUserIdentities(List<SysUserRelationGraphVo> nodes) {
|
||||
Map<Long, UserVO> profiles = new LinkedHashMap<>();
|
||||
for (SysUserRelationGraphVo node : nodes) {
|
||||
profiles.computeIfAbsent(node.getUserId(), userId -> {
|
||||
UserVO profile = new UserVO();
|
||||
profile.setUserId(userId);
|
||||
return profile;
|
||||
});
|
||||
}
|
||||
sysUserIdentityService.fillUserIdentities(new ArrayList<>(profiles.values()));
|
||||
for (SysUserRelationGraphVo node : nodes) {
|
||||
UserVO profile = profiles.get(node.getUserId());
|
||||
node.setIdentityTypes(profile.getIdentityTypes() == null ? List.of() : profile.getIdentityTypes());
|
||||
node.setStudentInfo(profile.getStudentInfo());
|
||||
}
|
||||
}
|
||||
|
||||
private SysUserRelationGraphVo buildGraphNode(RelationGraphNode node, Map<Long, SysUser> userMap,
|
||||
Map<Long, SysUserRelationTypeEntity> relationTypeMap) {
|
||||
SysUser user = userMap.get(node.userId);
|
||||
|
||||
+7
-6
@@ -10,6 +10,7 @@ import com.bsd.pjcloud.admin.mapper.user.SysUserRelationMapper;
|
||||
import com.bsd.pjcloud.admin.service.SysUserRelationTypeService;
|
||||
import com.bsd.pjcloud.admin.service.SysUserService;
|
||||
import com.bsd.pjcloud.admin.service.SysUserStudentService;
|
||||
import com.bsd.pjcloud.admin.service.SysUserIdentityService;
|
||||
import com.bsd.pjcloud.common.core.constant.CommonConstants;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
@@ -53,7 +54,7 @@ class SysUserRelationServiceImplTest {
|
||||
SysUserService sysUserService = mock(SysUserService.class);
|
||||
SysUserRelationTypeService relationTypeService = mock(SysUserRelationTypeService.class);
|
||||
SysUserRelationServiceImpl service = spy(
|
||||
new SysUserRelationServiceImpl(sysUserService, relationTypeService, mock(SysUserStudentService.class)));
|
||||
new SysUserRelationServiceImpl(sysUserService, relationTypeService, mock(SysUserStudentService.class), mock(SysUserIdentityService.class)));
|
||||
|
||||
when(sysUserService.getById(SECOND_WARD_USER_ID)).thenReturn(createUser(SECOND_WARD_USER_ID, "被监护人二"));
|
||||
when(sysUserService.listByIds(any())).thenReturn(List.of(
|
||||
@@ -83,7 +84,7 @@ class SysUserRelationServiceImplTest {
|
||||
SysUserRelationTypeService relationTypeService = mock(SysUserRelationTypeService.class);
|
||||
SysUserRelationServiceImpl service = spy(
|
||||
new SysUserRelationServiceImpl(
|
||||
mock(SysUserService.class), relationTypeService, mock(SysUserStudentService.class)));
|
||||
mock(SysUserService.class), relationTypeService, mock(SysUserStudentService.class), mock(SysUserIdentityService.class)));
|
||||
when(relationTypeService.getOne(any())).thenReturn(createGuardianRelationType());
|
||||
doReturn(List.of(
|
||||
createRelation(2079104982669385730L, FIRST_WARD_USER_ID),
|
||||
@@ -105,7 +106,7 @@ class SysUserRelationServiceImplTest {
|
||||
void shouldListDistinctUsersFromCompleteRelationGraph() {
|
||||
SysUserRelationServiceImpl service = spy(
|
||||
new SysUserRelationServiceImpl(mock(SysUserService.class), mock(SysUserRelationTypeService.class),
|
||||
mock(SysUserStudentService.class)));
|
||||
mock(SysUserStudentService.class), mock(SysUserIdentityService.class)));
|
||||
SysUserRelationEntity outgoingRelation = createRelation(2079104982669385730L, FIRST_WARD_USER_ID);
|
||||
SysUserRelationEntity incomingRelation = createRelation(2079129636280758273L, FIRST_WARD_USER_ID);
|
||||
incomingRelation.setSubjectUserId(SECOND_WARD_USER_ID);
|
||||
@@ -128,7 +129,7 @@ class SysUserRelationServiceImplTest {
|
||||
SysUserRelationTypeService relationTypeService = mock(SysUserRelationTypeService.class);
|
||||
SysUserStudentService studentService = mock(SysUserStudentService.class);
|
||||
SysUserRelationServiceImpl service = spy(
|
||||
new SysUserRelationServiceImpl(sysUserService, relationTypeService, studentService));
|
||||
new SysUserRelationServiceImpl(sysUserService, relationTypeService, studentService, mock(SysUserIdentityService.class)));
|
||||
|
||||
SysUserRelationEntity firstRelation = createRelation(1L, FIRST_WARD_USER_ID);
|
||||
SysUserRelationEntity secondRelation = createRelation(2L, FIRST_WARD_USER_ID);
|
||||
@@ -161,7 +162,7 @@ class SysUserRelationServiceImplTest {
|
||||
SysUserRelationTypeService relationTypeService = mock(SysUserRelationTypeService.class);
|
||||
SysUserStudentService studentService = mock(SysUserStudentService.class);
|
||||
SysUserRelationServiceImpl service = spy(new SysUserRelationServiceImpl(
|
||||
mock(SysUserService.class), relationTypeService, studentService));
|
||||
mock(SysUserService.class), relationTypeService, studentService, mock(SysUserIdentityService.class)));
|
||||
when(relationTypeService.list(org.mockito.ArgumentMatchers.<Wrapper<SysUserRelationTypeEntity>>any()))
|
||||
.thenReturn(List.of(createGuardianRelationType()));
|
||||
doReturn(List.of()).when(service)
|
||||
@@ -181,7 +182,7 @@ class SysUserRelationServiceImplTest {
|
||||
SysUserRelationTypeService relationTypeService = mock(SysUserRelationTypeService.class);
|
||||
SysUserRelationMapper relationMapper = mock(SysUserRelationMapper.class);
|
||||
SysUserRelationServiceImpl service = spy(new SysUserRelationServiceImpl(
|
||||
sysUserService, relationTypeService, mock(SysUserStudentService.class)));
|
||||
sysUserService, relationTypeService, mock(SysUserStudentService.class), mock(SysUserIdentityService.class)));
|
||||
ReflectionTestUtils.setField(service, "baseMapper", relationMapper);
|
||||
|
||||
SysUserRelationTypeEntity relationType = createGuardianRelationType();
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.bsd.pjcloud.wallet.api.constant;
|
||||
|
||||
/** 饭卡开卡费的持久化业务约定,供扣费幂等和流水展示共用。 */
|
||||
public final class WalletOpenCardFeeConstants {
|
||||
|
||||
/** 开卡费业务号前缀,后接 wallet_active 主键,卡片补打和换绑均沿用同一业务号。 */
|
||||
public static final String BIZ_ID_PREFIX = "OPEN_CARD:";
|
||||
|
||||
/** 工具常量类不允许创建实例。 */
|
||||
private WalletOpenCardFeeConstants() {
|
||||
}
|
||||
}
|
||||
+3
@@ -68,6 +68,9 @@ public final class WalletSysRemarkTemplate {
|
||||
/** 支付扣款兜底(无场景信息的钱包消费)。 */
|
||||
public static final String WALLET_PAY = "钱包消费{}元";
|
||||
|
||||
/** 打印饭卡首次收取开卡费,人工备注和系统备注使用同一金额文案。 */
|
||||
public static final String OPEN_CARD = "开卡费用{}元";
|
||||
|
||||
/** 充值到账(支付回调、后台充值)。 */
|
||||
public static final String WALLET_RECHARGE = "钱包充值{}元";
|
||||
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ public enum WalletTransactionTag {
|
||||
|
||||
BJZ_CZ("1", "不记账充值"),
|
||||
CS_CZ("2", "测试充值"),
|
||||
OPEN_CARD("3", "开卡金额"),
|
||||
;
|
||||
|
||||
private final String code;
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.bsd.pjcloud.wallet.api.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 打印饭卡前的整批开卡费预检与扣款参数,金额统一使用整数分。 */
|
||||
@Data
|
||||
@Schema(description = "打印饭卡开卡费参数")
|
||||
public class WalletOpenCardFeeDto {
|
||||
|
||||
/** 本次打印的开卡记录主键,由后台勾选记录提供,服务端仍校验数据权限。 */
|
||||
@NotEmpty(message = "请选择需要打印的饭卡")
|
||||
private List<@NotNull(message = "开卡记录编号不能为空") Long> ids;
|
||||
|
||||
/** 每张尚未收费卡片的开卡费,单位分,必须大于零且不随打印份数增加。 */
|
||||
@NotNull(message = "开卡金额不能为空")
|
||||
@Min(value = 1, message = "开卡金额必须大于0元")
|
||||
private Integer price;
|
||||
|
||||
/** 预检返回的逐卡绑定快照,预检可不传,扣款时必须完整提供且与当前绑定一致。 */
|
||||
@Valid
|
||||
private List<Snapshot> snapshots;
|
||||
|
||||
/** 操作者预检时看到的卡片归属,仅用于比较变化,不作为扣款目标的可信来源。 */
|
||||
@Data
|
||||
public static class Snapshot {
|
||||
|
||||
/** 对应的 wallet_active 主键,每个扣款批次内必须唯一。 */
|
||||
@NotNull(message = "开卡记录编号不能为空")
|
||||
private Long id;
|
||||
|
||||
/** 预检时绑定用户的主键,未绑定时为空并由业务预检阻止扣款。 */
|
||||
private Long userId;
|
||||
|
||||
/** 预检时绑定钱包的主键,服务端扣款时重新读取并比较。 */
|
||||
private Long walletId;
|
||||
|
||||
/** 预检时卡号,卡号被编辑后必须重新预检才能继续扣款和打印。 */
|
||||
private String cardNumber;
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -111,12 +111,15 @@ public class MchManageEntity extends Model<MchManageEntity> {
|
||||
@Schema(description = "商户手机号")
|
||||
private String phone;
|
||||
|
||||
/**
|
||||
* 商户营业时间
|
||||
*/
|
||||
@Schema(description = "商户营业时间")
|
||||
/** 旧客户端营业时间兼容串,餐段编码 N 对应第 N 个逗号位置,仅投影首个可表示时段。 */
|
||||
@Schema(description = "旧版营业时间兼容串,新客户端优先使用mealTimeConfig")
|
||||
private String jobTime;
|
||||
|
||||
/** 按 daily_menu 编码保存的用餐时间;null 时兼容读取 jobTime,空 periods 表示明确清空。 */
|
||||
@Schema(description = "结构化用餐时间,支持多时段、时间点和跨天")
|
||||
@TableField(typeHandler = MchManageJacksonTypeHandler.class)
|
||||
private MchMealTimeConfig mealTimeConfig;
|
||||
|
||||
|
||||
/**
|
||||
* 商户地址
|
||||
@@ -251,6 +254,12 @@ public class MchManageEntity extends Model<MchManageEntity> {
|
||||
@Data
|
||||
public static class TableReservationConf {
|
||||
|
||||
/**
|
||||
* 商户启用的订餐餐段编码,来自 daily_menu 字典并按字典顺序保存。
|
||||
* 历史 JSON 缺少该属性时为 null,表示沿用原餐段范围;明确保存时至少选择一个餐段。
|
||||
*/
|
||||
private List<String> hourTypes;
|
||||
|
||||
/**
|
||||
* 订餐模式
|
||||
*
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.bsd.pjcloud.wallet.api.entity.mch;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 商户用餐时间 JSON 契约,餐段身份由字典编码确定,展示顺序由字典维护。 */
|
||||
@Data
|
||||
@Schema(description = "商户用餐时间配置")
|
||||
public class MchMealTimeConfig implements Serializable {
|
||||
|
||||
/** daily_menu 编码到用餐时段的映射;空映射表示无时间,停用餐段的历史时间仍保留。 */
|
||||
@Schema(description = "餐段编码对应的多个用餐时段")
|
||||
private Map<String, List<Period>> periods = new LinkedHashMap<>();
|
||||
|
||||
/** 一个用餐时段,起止相同且当天结束时表示用餐时间点。 */
|
||||
@Data
|
||||
@Schema(description = "用餐时段或时间点")
|
||||
public static class Period implements Serializable {
|
||||
|
||||
/** 当地开始时刻,使用 HH:mm 或 HH:mm:ss,不含日期与时区。 */
|
||||
@Schema(description = "开始时刻,HH:mm或HH:mm:ss", example = "11:18")
|
||||
private String startTime;
|
||||
|
||||
/** 当地结束时刻,使用 HH:mm 或 HH:mm:ss,相同起止时刻可表示时间点。 */
|
||||
@Schema(description = "结束时刻,HH:mm或HH:mm:ss", example = "11:18")
|
||||
private String endTime;
|
||||
|
||||
/** 相对开始日期的结束日偏移,0 当天、1 次日;省略时按当天处理。 */
|
||||
@Schema(description = "结束日偏移,0当天、1次日", example = "0")
|
||||
private Integer endDayOffset = 0;
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package com.bsd.pjcloud.wallet.api.utils;
|
||||
|
||||
import com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity;
|
||||
import com.bsd.pjcloud.wallet.api.entity.mch.MchMealTimeConfig;
|
||||
import com.bsd.pjcloud.wallet.api.entity.mch.MchMealTimeConfig.Period;
|
||||
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** 用餐时间的兼容解析与写入合并,供商户服务及跨模块时间消费者共用。 */
|
||||
public final class MchMealTimeUtils {
|
||||
|
||||
/** 旧字段仍保留原 VARCHAR(256) 契约,超出投影能力的餐段完整保存在 JSON 中。 */
|
||||
private static final int LEGACY_MAX_LENGTH = 256;
|
||||
/** 旧四餐客户端依赖的最少逗号位置数。 */
|
||||
private static final int LEGACY_MIN_SLOTS = 4;
|
||||
/** 当天秒数,用于明确计算结束日偏移。 */
|
||||
private static final int DAY_SECONDS = 24 * 60 * 60;
|
||||
/** 分钟精度时刻的标准输出格式,历史非零秒保持秒精度。 */
|
||||
private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
/** 工具类仅提供无状态静态方法。 */
|
||||
private MchMealTimeUtils() {
|
||||
}
|
||||
|
||||
/** 严格读取有效时刻,兼容历史单数字小时,禁止无效小时、分钟或尾随文本。 */
|
||||
public static LocalTime parseTime(String value) {
|
||||
if (value == null || !value.trim().matches("(?:[0-9]|[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?")) {
|
||||
throw new IllegalArgumentException("用餐时间须为有效的HH:mm或HH:mm:ss时刻");
|
||||
}
|
||||
String[] parts = value.trim().split(":");
|
||||
return LocalTime.of(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), parts.length == 3 ? Integer.parseInt(parts[2]) : 0);
|
||||
}
|
||||
|
||||
/** 标准化一个时段;当天等时刻为时间点,次日结束必须明确设置。 */
|
||||
public static Period normalizePeriod(Period source) {
|
||||
if (source == null) throw new IllegalArgumentException("用餐时段不能为空");
|
||||
LocalTime start = parseTime(source.getStartTime());
|
||||
LocalTime end = parseTime(source.getEndTime());
|
||||
int offset = source.getEndDayOffset() == null ? 0 : source.getEndDayOffset();
|
||||
if (offset != 0 && offset != 1) throw new IllegalArgumentException("结束日偏移只能为0或1");
|
||||
int duration = end.toSecondOfDay() + offset * DAY_SECONDS - start.toSecondOfDay();
|
||||
if (duration < 0) {
|
||||
throw new IllegalArgumentException("结束时刻早于开始时刻时请选择次日结束");
|
||||
}
|
||||
Period target = new Period();
|
||||
target.setStartTime(formatTime(start));
|
||||
target.setEndTime(formatTime(end));
|
||||
target.setEndDayOffset(offset);
|
||||
return target;
|
||||
}
|
||||
|
||||
/** 规范化 JSON 并深拷贝时段,避免部分更新修改查询对象;空映射和空餐段列表均支持清空。 */
|
||||
public static MchMealTimeConfig normalize(MchMealTimeConfig source) {
|
||||
if (source == null || source.getPeriods() == null) throw new IllegalArgumentException("请提交完整的用餐时间配置");
|
||||
MchMealTimeConfig target = new MchMealTimeConfig();
|
||||
for (Map.Entry<String, List<Period>> entry : source.getPeriods().entrySet()) {
|
||||
String code = entry.getKey() == null ? "" : entry.getKey().trim();
|
||||
if (code.isEmpty() || target.getPeriods().containsKey(code) || entry.getValue() == null) {
|
||||
throw new IllegalArgumentException("用餐时间的餐段编码或时段列表无效");
|
||||
}
|
||||
List<Period> periods = entry.getValue().stream().map(MchMealTimeUtils::normalizePeriod).distinct()
|
||||
.sorted(Comparator.comparing(p -> parseTime(p.getStartTime()))).toList();
|
||||
target.getPeriods().put(code, periods);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/** 解析旧版逗号位置,空位保持餐段身份;坏数据跳过,原串由数据库保留以供修正。 */
|
||||
public static MchMealTimeConfig fromLegacy(String jobTime) {
|
||||
MchMealTimeConfig target = new MchMealTimeConfig();
|
||||
if (jobTime == null || jobTime.isBlank()) return target;
|
||||
String[] slots = jobTime.split(",", -1);
|
||||
for (int i = 0; i < slots.length; i++) {
|
||||
if (slots[i].isBlank()) continue;
|
||||
try {
|
||||
target.getPeriods().put(String.valueOf(i + 1), List.of(parseLegacyPeriod(slots[i])));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// 读取兼容允许历史坏值存在,迁移会列出异常行,新写入仍严格校验。
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/** 新配置存在时始终优先使用,包括明确的空配置;缺失时才读取旧字段。 */
|
||||
public static MchMealTimeConfig resolve(MchManageEntity merchant) {
|
||||
if (merchant == null) return new MchMealTimeConfig();
|
||||
return merchant.getMealTimeConfig() == null ? fromLegacy(merchant.getJobTime()) : merchant.getMealTimeConfig();
|
||||
}
|
||||
|
||||
/** 返回已启用餐段的完整时段,隐藏餐段仅影响生效范围,其保存数据仍留在配置中。 */
|
||||
public static List<Period> enabledPeriods(MchManageEntity merchant) {
|
||||
if (merchant == null) return List.of();
|
||||
List<String> enabled = merchant.getTableReservationConf() == null ? null : merchant.getTableReservationConf().getHourTypes();
|
||||
return resolve(merchant).getPeriods().entrySet().stream()
|
||||
.filter(entry -> enabled == null || enabled.contains(entry.getKey()))
|
||||
.filter(entry -> entry.getValue() != null)
|
||||
.flatMap(entry -> entry.getValue().stream())
|
||||
.sorted(Comparator.comparing(p -> parseTime(p.getStartTime()))).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并旧客户端实际提交且发生变化的位置,未提交的尾部及未改变餐段的多个时段保持原样。
|
||||
* 比较数据库原串可避免旧四餐客户端截断编码 10、11,也避免回显首时段覆盖新配置。
|
||||
*/
|
||||
public static MchMealTimeConfig mergeLegacyUpdate(MchManageEntity existing, String incoming) {
|
||||
MchMealTimeConfig target = normalize(resolve(existing));
|
||||
String[] before = Objects.toString(existing == null ? null : existing.getJobTime(), "").split(",", -1);
|
||||
String[] after = Objects.toString(incoming, "").split(",", -1);
|
||||
for (int i = 0; i < after.length; i++) {
|
||||
String oldValue = i < before.length ? before[i].trim() : "";
|
||||
String newValue = after[i].trim();
|
||||
if (oldValue.equals(newValue)) continue;
|
||||
String code = String.valueOf(i + 1);
|
||||
if (newValue.isEmpty()) {
|
||||
target.getPeriods().remove(code);
|
||||
continue;
|
||||
}
|
||||
Period period = parseLegacyPeriod(newValue);
|
||||
List<Period> saved = target.getPeriods().get(code);
|
||||
if (saved != null && !saved.isEmpty() && saved.get(0).equals(period)) continue;
|
||||
target.getPeriods().put(code, List.of(period));
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成旧客户端首时段投影,仍保留四个固定位置和正整数编码位置。
|
||||
* 非数字编码、超过 256 字符能力及旧串无法表达结束日的项由 JSON 完整承载。
|
||||
*/
|
||||
public static String toLegacy(MchMealTimeConfig config) {
|
||||
Map<Integer, String> slots = new LinkedHashMap<>();
|
||||
config.getPeriods().forEach((code, periods) -> {
|
||||
if (!code.matches("[1-9][0-9]{0,2}") || periods.isEmpty()) return;
|
||||
int index = Integer.parseInt(code);
|
||||
if (index > LEGACY_MAX_LENGTH) return;
|
||||
Period first = periods.get(0);
|
||||
if (first.getEndDayOffset() == 1 && !parseTime(first.getEndTime()).isBefore(parseTime(first.getStartTime()))) return;
|
||||
slots.put(index, first.getStartTime() + "-" + first.getEndTime());
|
||||
});
|
||||
List<String> parts = new ArrayList<>();
|
||||
int length = 0;
|
||||
int count = Math.max(LEGACY_MIN_SLOTS, slots.keySet().stream().max(Integer::compareTo).orElse(0));
|
||||
for (int i = 1; i <= count; i++) {
|
||||
String part = slots.getOrDefault(i, "");
|
||||
int nextLength = length + (i == 1 ? 0 : 1) + part.length();
|
||||
if (nextLength > LEGACY_MAX_LENGTH) break;
|
||||
parts.add(part);
|
||||
length = nextLength;
|
||||
}
|
||||
return String.join(",", parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将每日循环的完整营业时间投影成当天时刻区间,供既有配送协议使用。
|
||||
* 跨日区间拆到当天两端,时间点仍可校验立即送;预约格子继续要求正时长且当天结束。
|
||||
*/
|
||||
public static String toDailyIntervals(MchManageEntity merchant) {
|
||||
List<String> intervals = new ArrayList<>();
|
||||
for (Period period : enabledPeriods(merchant)) {
|
||||
if (Objects.equals(period.getEndDayOffset(), 1)) {
|
||||
intervals.add("00:00-" + period.getEndTime());
|
||||
intervals.add(period.getStartTime() + "-23:59:59");
|
||||
} else {
|
||||
intervals.add(period.getStartTime() + "-" + period.getEndTime());
|
||||
}
|
||||
}
|
||||
return String.join(",", intervals);
|
||||
}
|
||||
|
||||
/**
|
||||
* 报餐沿用当天正时长区间、按开始时刻分类及排序的既有协议。
|
||||
* 同时刻按旧餐段位置排序,报餐历史 segmentIndex 不因 JSON 对象的键序变化而漂移。
|
||||
*/
|
||||
public static String toReportingIntervals(MchManageEntity merchant) {
|
||||
return resolve(merchant).getPeriods().entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey(Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder())))
|
||||
.flatMap(entry -> entry.getValue().stream())
|
||||
.filter(p -> !Objects.equals(p.getEndDayOffset(), 1) && parseTime(p.getEndTime()).isAfter(parseTime(p.getStartTime())))
|
||||
.map(p -> p.getStartTime() + "-" + p.getEndTime()).collect(java.util.stream.Collectors.joining(","));
|
||||
}
|
||||
|
||||
/** 读取一个旧区间,早结束推断为次日,等时刻保留为当天时间点。 */
|
||||
private static Period parseLegacyPeriod(String value) {
|
||||
String[] parts = value.trim().split("-", -1);
|
||||
if (parts.length != 2) throw new IllegalArgumentException("营业时间格式无效,请重新配置用餐时间");
|
||||
Period period = new Period();
|
||||
period.setStartTime(parts[0]);
|
||||
period.setEndTime(parts[1]);
|
||||
period.setEndDayOffset(parseTime(parts[1]).isBefore(parseTime(parts[0])) ? 1 : 0);
|
||||
return normalizePeriod(period);
|
||||
}
|
||||
|
||||
/** 零秒时以分钟输出,非零秒以秒输出,兼容旧数据且避免编辑过程丢失精度。 */
|
||||
private static String formatTime(LocalTime value) {
|
||||
return value.getSecond() == 0 ? value.format(HM) : value.toString();
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.bsd.pjcloud.wallet.api.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 打印饭卡开卡费的逐卡检查结果与批次汇总,扣费和打印状态分别展示。 */
|
||||
@Data
|
||||
public class WalletOpenCardFeeVo {
|
||||
|
||||
/** 按请求卡片顺序返回的预检或扣款结果,越权记录仅返回主键及通用错误。 */
|
||||
private List<Item> items;
|
||||
|
||||
/** 本次可正常收费且尚未收过开卡费的卡片数量。 */
|
||||
private int pendingCount;
|
||||
|
||||
/** 当前检查正常且已有开卡费流水的卡片数量。 */
|
||||
private int chargedCount;
|
||||
|
||||
/** 本次检查异常的卡片数量,大于零时整批禁止扣款及打印。 */
|
||||
private int abnormalCount;
|
||||
|
||||
/** 本次正常待收费卡片的金额合计,单位分,使用 long 避免批次求和溢出。 */
|
||||
private long totalPrice;
|
||||
|
||||
/** 扣费检查状态协议,序列化为枚举名称,前端据此展示中文状态。 */
|
||||
public enum State {
|
||||
/** 卡片及钱包检查通过,本次需要首次收取开卡费。 */
|
||||
PENDING,
|
||||
/** 已存在该开卡记录的收费流水,本次无需重复扣款。 */
|
||||
CHARGED,
|
||||
/** 卡片、用户、钱包或余额异常,必须处理后重新检查。 */
|
||||
BLOCKED
|
||||
}
|
||||
|
||||
/** 单张开卡记录的最新打印资料、钱包余额及收费结果。 */
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public static class Item extends WalletActiveVo {
|
||||
|
||||
/** 当前钱包余额,单位分,钱包不存在或无权访问时为空。 */
|
||||
private Integer balance;
|
||||
|
||||
/** 本次拟扣金额,单位分,已收费或异常卡片为零。 */
|
||||
private Integer chargePrice;
|
||||
|
||||
/** 历史已收取的开卡费,单位分,首次收费前为零。 */
|
||||
private Integer chargedPrice;
|
||||
|
||||
/** 该卡首次成功收取开卡费的流水编号,尚未收费时为空。 */
|
||||
private Long transactionId;
|
||||
|
||||
/** 当前扣费检查状态,与前端本次打印任务状态分别维护。 */
|
||||
private State feeState;
|
||||
|
||||
/** 无法扣款或打印的中文原因,检查通过时为空。 */
|
||||
private String reason;
|
||||
}
|
||||
}
|
||||
+6
@@ -1,5 +1,7 @@
|
||||
package com.bsd.pjcloud.wallet.api.vo.recharge.app;
|
||||
|
||||
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
@@ -80,6 +82,10 @@ public class GuardianWardRechargeItemVo {
|
||||
*/
|
||||
private List<String> identityTypes;
|
||||
|
||||
/** 学生的学校、学号、班级和监护人手机号;非学生或未维护时为空,不返回非核心扩展 JSON。 */
|
||||
@JsonIgnoreProperties("extJson")
|
||||
private SysUserStudentDto studentInfo;
|
||||
|
||||
/**
|
||||
* 审核通过且当前可用的人脸数量。
|
||||
*/
|
||||
|
||||
+29
@@ -24,8 +24,10 @@ import com.bsd.pjcloud.wallet.api.dto.*;
|
||||
import com.bsd.pjcloud.wallet.api.entity.WalletActiveEntity;
|
||||
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
|
||||
import com.bsd.pjcloud.wallet.api.vo.WalletActiveVo;
|
||||
import com.bsd.pjcloud.wallet.api.vo.WalletOpenCardFeeVo;
|
||||
import com.bsd.pjcloud.wallet.service.MchManageService;
|
||||
import com.bsd.pjcloud.wallet.service.WalletActiveService;
|
||||
import com.bsd.pjcloud.wallet.service.WalletOpenCardFeeService;
|
||||
import com.bsd.pjcloud.wallet.service.WalletActiveImportService;
|
||||
import com.bsd.pjcloud.wallet.service.WalletUserExportService;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
@@ -58,6 +60,8 @@ import java.util.List;
|
||||
public class WalletActiveController {
|
||||
|
||||
private final WalletActiveService walletActiveService;
|
||||
/** 饭卡打印前的开卡费预检与整批幂等扣款服务。 */
|
||||
private final WalletOpenCardFeeService walletOpenCardFeeService;
|
||||
/**
|
||||
* 钱包开卡 Excel 导入服务,隔离默认和学生模板的校验及异步编排。
|
||||
*/
|
||||
@@ -174,6 +178,31 @@ public class WalletActiveController {
|
||||
return R.ok(walletActiveService.refreshAvatar(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 预检选中饭卡的用户、钱包及首次收费状态,异常逐卡返回供打印抽屉展示。
|
||||
* @param dto 开卡记录主键和单卡费用,单位分
|
||||
* @return 当前权限范围内的逐卡结果及批次金额汇总
|
||||
*/
|
||||
@PostMapping("/open-card-fee/preview")
|
||||
@Operation(summary = "打印饭卡开卡费预检")
|
||||
@PreAuthorize("@pms.hasPermission('wallet_walletActive_print')")
|
||||
public R<WalletOpenCardFeeVo> previewOpenCardFee(@Valid @RequestBody WalletOpenCardFeeDto dto) {
|
||||
return R.ok(walletOpenCardFeeService.preview(dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* 整批收取尚未收费卡片的开卡费,绑定变化或任一卡异常均终止整个批次。
|
||||
* @param dto 卡片、金额及操作者预检时的绑定快照
|
||||
* @return 扣款成功后的逐卡状态和实际钱包余额
|
||||
*/
|
||||
@PostMapping("/open-card-fee/charge")
|
||||
@Operation(summary = "打印饭卡整批收取开卡费")
|
||||
@SysLog("打印饭卡收取开卡费")
|
||||
@PreAuthorize("@pms.hasPermission('wallet_walletActive_print')")
|
||||
public R<WalletOpenCardFeeVo> chargeOpenCardFee(@Valid @RequestBody WalletOpenCardFeeDto dto) {
|
||||
return R.ok(walletOpenCardFeeService.charge(dto));
|
||||
}
|
||||
|
||||
@PostMapping("/fast-create-card")
|
||||
@Operation(summary = "一键开卡")
|
||||
@Idempotent(info = "正在开卡中,请勿重复提交")
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.bsd.pjcloud.wallet.service;
|
||||
|
||||
import com.bsd.pjcloud.wallet.api.dto.WalletOpenCardFeeDto;
|
||||
import com.bsd.pjcloud.wallet.api.vo.WalletOpenCardFeeVo;
|
||||
|
||||
/** 打印饭卡前的开卡费校验及整批幂等扣款服务。 */
|
||||
public interface WalletOpenCardFeeService {
|
||||
|
||||
/**
|
||||
* 读取当前权限内的卡片、用户、钱包与收费流水并逐卡预检,不改变资金。
|
||||
* @param dto 开卡记录主键和单卡费用,金额单位分
|
||||
* @return 按请求顺序返回的逐卡异常、余额、历史收费与批次汇总
|
||||
*/
|
||||
WalletOpenCardFeeVo preview(WalletOpenCardFeeDto dto);
|
||||
|
||||
/**
|
||||
* 锁定卡片和钱包后复核绑定快照,在同一事务中完成整批首次收费。
|
||||
* @param dto 本批卡片、单卡费用及预检绑定快照
|
||||
* @return 提交前的整批收费结果,任一异常均抛错并回滚本批全部资金变动
|
||||
*/
|
||||
WalletOpenCardFeeVo charge(WalletOpenCardFeeDto dto);
|
||||
}
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
package com.bsd.pjcloud.wallet.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.bsd.pjcloud.common.core.util.BeanUtils;
|
||||
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScope;
|
||||
import com.bsd.pjcloud.wallet.api.constant.WalleCardStatusEnum;
|
||||
import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
|
||||
import com.bsd.pjcloud.wallet.api.constant.WalletOpenCardFeeConstants;
|
||||
import com.bsd.pjcloud.wallet.api.constant.WalletSysRemarkTemplate;
|
||||
import com.bsd.pjcloud.wallet.api.constant.WalletTransactionTag;
|
||||
import com.bsd.pjcloud.wallet.api.dto.WalletActiveDto;
|
||||
import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto;
|
||||
import com.bsd.pjcloud.wallet.api.dto.WalletOpenCardFeeDto;
|
||||
import com.bsd.pjcloud.wallet.api.entity.WalletActiveEntity;
|
||||
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
|
||||
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
|
||||
import com.bsd.pjcloud.wallet.api.vo.WalletOpenCardFeeVo;
|
||||
import com.bsd.pjcloud.wallet.api.vo.WalletOpenCardFeeVo.Item;
|
||||
import com.bsd.pjcloud.wallet.api.vo.WalletOpenCardFeeVo.State;
|
||||
import com.bsd.pjcloud.wallet.mapper.WalletActiveMapper;
|
||||
import com.bsd.pjcloud.wallet.mapper.WalletManageMapper;
|
||||
import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper;
|
||||
import com.bsd.pjcloud.wallet.service.WalletOpenCardFeeService;
|
||||
import com.bsd.pjcloud.wallet.service.WalletTransactionService;
|
||||
import com.bsd.pjcloud.wallet.support.WalletMessageContextHolder;
|
||||
import com.bsd.pjcloud.wallet.support.WalletUserIdentitySupport;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Isolation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** 打印饭卡的整批开卡费处理,卡片锁与历史流水共同保证同一开卡记录只收费一次。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WalletOpenCardFeeServiceImpl implements WalletOpenCardFeeService {
|
||||
|
||||
/** 开卡记录查询及行锁,数据权限沿用开卡列表的公共 Mapper 能力。 */
|
||||
private final WalletActiveMapper walletActiveMapper;
|
||||
|
||||
/** 钱包查询与行锁,保护绑定状态、余额检查和整个扣款批次。 */
|
||||
private final WalletManageMapper walletManageMapper;
|
||||
|
||||
/** 按稳定业务号查询首次收费流水,扣款时必须使用当前读。 */
|
||||
private final WalletTransactionMapper walletTransactionMapper;
|
||||
|
||||
/** 复用批量幂等消费扣款及流水、Outbox 的同事务保存能力。 */
|
||||
private final WalletTransactionService walletTransactionService;
|
||||
|
||||
/** 核实绑定用户在当前人员权限内存在,并补齐打印需要的最新身份资料。 */
|
||||
private final WalletUserIdentitySupport userIdentitySupport;
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public WalletOpenCardFeeVo preview(WalletOpenCardFeeDto dto) {
|
||||
return loadResult(dto, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
* 卡片行锁保证单卡收费串行,READ_COMMITTED 减少不存在收费业务号之间的间隙锁竞争。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_COMMITTED)
|
||||
public WalletOpenCardFeeVo charge(WalletOpenCardFeeDto dto) {
|
||||
List<Long> ids = validateRequest(dto);
|
||||
Map<Long, WalletOpenCardFeeDto.Snapshot> snapshots = validateSnapshots(dto, ids);
|
||||
WalletOpenCardFeeVo result = loadResult(dto, true);
|
||||
for (Item item : result.getItems()) {
|
||||
WalletOpenCardFeeDto.Snapshot snapshot = snapshots.get(item.getId());
|
||||
if (!Objects.equals(item.getUserId(), snapshot.getUserId())
|
||||
|| !Objects.equals(item.getWalletId(), snapshot.getWalletId())
|
||||
|| !Objects.equals(item.getCardNumber(), snapshot.getCardNumber())) {
|
||||
block(item, "卡片绑定或卡号已变化,请刷新检查后重试");
|
||||
}
|
||||
}
|
||||
summarize(result);
|
||||
// 预检只是提示。真正扣款前在同一事务内重新检查整批,任何异常都不进入资金写入。
|
||||
if (result.getAbnormalCount() > 0) {
|
||||
return result;
|
||||
}
|
||||
List<WalletBalanceTransactionDto> payments = new ArrayList<>();
|
||||
for (Item item : result.getItems()) {
|
||||
if (item.getTransactionId() != null) {
|
||||
continue;
|
||||
}
|
||||
WalletBalanceTransactionDto payment = new WalletBalanceTransactionDto();
|
||||
payment.setWalletId(item.getWalletId());
|
||||
payment.setPrice(dto.getPrice());
|
||||
payment.setBizId(bizId(item.getId()));
|
||||
payment.setBizType(WalletBizEnum.PAYMENT);
|
||||
payment.setForce(false);
|
||||
payment.setTags(WalletTransactionTag.OPEN_CARD.getCode());
|
||||
String remark = WalletSysRemarkTemplate.format(WalletSysRemarkTemplate.OPEN_CARD, dto.getPrice());
|
||||
payment.setRemark(remark);
|
||||
payment.setSysRemark(remark);
|
||||
payment.setSourceScene("WALLET");
|
||||
payments.add(payment);
|
||||
}
|
||||
if (payments.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
// 上下文让现有流水保存逻辑在首次 INSERT 前同时写入 remark 和 sysRemark,并生成完整消息快照。
|
||||
List<WalletTransactionEntity> transactions = WalletMessageContextHolder.supplyWith(payments,
|
||||
() -> walletTransactionService.reduceWalletBalanceBatchIdempotent(payments));
|
||||
if (transactions == null || transactions.size() != payments.size()) {
|
||||
throw new IllegalStateException("开卡费扣款结果不完整,请查询收费状态后重试");
|
||||
}
|
||||
Map<String, WalletTransactionEntity> charged = new HashMap<>();
|
||||
for (WalletTransactionEntity transaction : transactions) {
|
||||
if (transaction == null || transaction.getId() == null) {
|
||||
throw new IllegalStateException("开卡费流水保存失败");
|
||||
}
|
||||
charged.put(transaction.getBizId(), transaction);
|
||||
}
|
||||
Map<Long, WalletManageEntity> wallets = loadWallets(result.getItems(), true);
|
||||
for (Item item : result.getItems()) {
|
||||
WalletTransactionEntity transaction = charged.get(bizId(item.getId()));
|
||||
if (transaction != null) {
|
||||
fillCharged(item, transaction);
|
||||
}
|
||||
item.setBalance(wallets.get(item.getWalletId()).getBalance());
|
||||
item.setChargePrice(0);
|
||||
item.setFeeState(State.CHARGED);
|
||||
}
|
||||
summarize(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前权限下的最新数据并计算整批结果,扣款场景先锁卡再按钱包主键升序锁钱包。
|
||||
* @param dto 卡片及单卡金额
|
||||
* @param locking 是否在调用方事务内锁定卡片、钱包及已有收费流水
|
||||
* @return 逐卡检查结果,异常记录不会泄露权限外的卡号或用户资料
|
||||
*/
|
||||
private WalletOpenCardFeeVo loadResult(WalletOpenCardFeeDto dto, boolean locking) {
|
||||
List<Long> ids = validateRequest(dto);
|
||||
List<WalletActiveEntity> scoped = walletActiveMapper.selectListByScope(
|
||||
new LambdaQueryWrapper<WalletActiveEntity>().in(WalletActiveEntity::getId, ids), DataScope.of());
|
||||
Map<Long, WalletActiveEntity> cards = new HashMap<>();
|
||||
for (WalletActiveEntity card : scoped.stream().sorted(java.util.Comparator.comparing(WalletActiveEntity::getId)).toList()) {
|
||||
WalletActiveEntity current = card;
|
||||
if (locking) {
|
||||
// 数据权限组件会包装子查询,因此在权限查询之后单独按主键锁行,避免子查询锁定无权访问的卡片。
|
||||
current = walletActiveMapper.selectOne(new LambdaQueryWrapper<WalletActiveEntity>()
|
||||
.eq(WalletActiveEntity::getId, card.getId()).last("FOR UPDATE"));
|
||||
}
|
||||
// 等待行锁期间归属部门或创建人变化时,原权限检查已失效,必须重新预检。
|
||||
if (current != null && Objects.equals(current.getDeptId(), card.getDeptId())
|
||||
&& Objects.equals(current.getCreateBy(), card.getCreateBy())) {
|
||||
cards.put(current.getId(), current);
|
||||
}
|
||||
}
|
||||
List<Item> items = new ArrayList<>();
|
||||
for (Long id : ids) {
|
||||
WalletActiveEntity card = cards.get(id);
|
||||
Item item = card == null ? new Item() : BeanUtils.toBean(card, Item.class);
|
||||
item.setId(id);
|
||||
item.setChargePrice(0);
|
||||
item.setChargedPrice(0);
|
||||
item.setFeeState(State.PENDING);
|
||||
items.add(item);
|
||||
}
|
||||
Map<Long, WalletManageEntity> wallets = loadWallets(items, locking);
|
||||
Map<String, WalletTransactionEntity> charged = loadCharged(cards.keySet(), locking);
|
||||
List<Long> userIds = items.stream().map(Item::getUserId).filter(Objects::nonNull).distinct().toList();
|
||||
Set<Long> existingUsers = userIds.isEmpty() ? Set.of() : new HashSet<>(
|
||||
userIdentitySupport.findIdentityUserIds(new WalletActiveDto(), null, userIds));
|
||||
userIdentitySupport.fillUserInfo(items, true);
|
||||
for (Item item : items) {
|
||||
WalletTransactionEntity transaction = charged.get(bizId(item.getId()));
|
||||
if (transaction != null) {
|
||||
fillCharged(item, transaction);
|
||||
}
|
||||
if (!cards.containsKey(item.getId())) {
|
||||
block(item, "开卡记录不存在或无权操作");
|
||||
continue;
|
||||
}
|
||||
WalletManageEntity wallet = wallets.get(item.getWalletId());
|
||||
item.setBalance(wallet == null ? null : wallet.getBalance());
|
||||
String reason = invalidReason(item, wallet, existingUsers);
|
||||
if (reason != null) {
|
||||
block(item, reason);
|
||||
} else if (transaction == null) {
|
||||
item.setChargePrice(dto.getPrice());
|
||||
}
|
||||
}
|
||||
// 同一钱包若对应多笔待收费明细,必须按合计判断余额,不能让逐行预检错误地全部通过。
|
||||
Map<Long, Long> totals = new HashMap<>();
|
||||
items.stream().filter(item -> item.getFeeState() == State.PENDING)
|
||||
.forEach(item -> totals.merge(item.getWalletId(), item.getChargePrice().longValue(), Long::sum));
|
||||
for (Item item : items) {
|
||||
if (item.getFeeState() == State.PENDING && item.getBalance() < totals.get(item.getWalletId())) {
|
||||
block(item, "余额不足,无法支付本批开卡费");
|
||||
}
|
||||
}
|
||||
WalletOpenCardFeeVo result = new WalletOpenCardFeeVo();
|
||||
result.setItems(items);
|
||||
summarize(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验基础参数并按首次出现顺序去重,防止重复勾选产生重复收费或空条件查询。
|
||||
* @param dto 本次请求
|
||||
* @return 非空且不含空值的开卡记录主键
|
||||
*/
|
||||
private List<Long> validateRequest(WalletOpenCardFeeDto dto) {
|
||||
if (dto == null || dto.getIds() == null || dto.getIds().isEmpty() || dto.getIds().stream().anyMatch(Objects::isNull)) {
|
||||
throw new IllegalArgumentException("请选择有效的饭卡记录");
|
||||
}
|
||||
if (dto.getPrice() == null || dto.getPrice() <= 0) {
|
||||
throw new IllegalArgumentException("开卡金额必须大于0元");
|
||||
}
|
||||
return dto.getIds().stream().distinct().toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 扣款必须携带完整且唯一的预检快照,拒绝缺失快照时直接按最新用户扣费。
|
||||
* @param dto 本次扣款参数
|
||||
* @param ids 去重后的本批记录主键
|
||||
* @return 按开卡记录主键索引的绑定快照
|
||||
*/
|
||||
private Map<Long, WalletOpenCardFeeDto.Snapshot> validateSnapshots(WalletOpenCardFeeDto dto, List<Long> ids) {
|
||||
Map<Long, WalletOpenCardFeeDto.Snapshot> snapshots = new HashMap<>();
|
||||
if (dto.getSnapshots() != null) {
|
||||
for (WalletOpenCardFeeDto.Snapshot snapshot : dto.getSnapshots()) {
|
||||
if (snapshot == null || snapshot.getId() == null || snapshots.putIfAbsent(snapshot.getId(), snapshot) != null) {
|
||||
throw new IllegalArgumentException("卡片预检快照无效,请刷新检查后重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!snapshots.keySet().equals(new HashSet<>(ids))) {
|
||||
throw new IllegalArgumentException("缺少完整的卡片预检快照,请刷新检查后重试");
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只读取已经通过卡片权限查询所关联的钱包,锁定时统一按主键升序执行。
|
||||
* @param items 当前权限内的开卡结果
|
||||
* @param locking 是否锁定钱包行并使用当前读
|
||||
* @return 实际存在的钱包,历史缺失关联不会自动创建钱包
|
||||
*/
|
||||
private Map<Long, WalletManageEntity> loadWallets(List<Item> items, boolean locking) {
|
||||
Map<Long, WalletManageEntity> wallets = new LinkedHashMap<>();
|
||||
List<Long> ids = items.stream().map(Item::getWalletId).filter(Objects::nonNull).distinct().sorted().toList();
|
||||
for (Long id : ids) {
|
||||
WalletManageEntity wallet = locking ? walletManageMapper.selectByIdForUpdate(id) : walletManageMapper.selectById(id);
|
||||
if (wallet != null) {
|
||||
wallets.put(id, wallet);
|
||||
}
|
||||
}
|
||||
return wallets;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按开卡业务号读取收费凭据,不依赖可编辑备注、标签或卡片当前绑定用户。
|
||||
* @param cardIds 当前权限内存在的开卡记录主键
|
||||
* @param locking 是否锁定读,避免等待卡片锁后仍使用旧的一致性快照重复收费
|
||||
* @return 每张卡最早的有效收费流水,未收费的卡不出现在结果中
|
||||
*/
|
||||
private Map<String, WalletTransactionEntity> loadCharged(Set<Long> cardIds, boolean locking) {
|
||||
Map<String, WalletTransactionEntity> charged = new HashMap<>();
|
||||
if (cardIds.isEmpty()) {
|
||||
return charged;
|
||||
}
|
||||
LambdaQueryWrapper<WalletTransactionEntity> query = new LambdaQueryWrapper<WalletTransactionEntity>()
|
||||
.in(WalletTransactionEntity::getBizId, cardIds.stream().map(this::bizId).toList())
|
||||
.eq(WalletTransactionEntity::getBizType, WalletBizEnum.PAYMENT.getCode())
|
||||
.lt(WalletTransactionEntity::getPrice, 0)
|
||||
.orderByAsc(WalletTransactionEntity::getId);
|
||||
if (locking) {
|
||||
query.last("FOR UPDATE");
|
||||
}
|
||||
for (WalletTransactionEntity transaction : walletTransactionMapper.selectList(query)) {
|
||||
charged.putIfAbsent(transaction.getBizId(), transaction);
|
||||
}
|
||||
return charged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验用户、钱包归属及可用状态,尚未硬件激活不影响首次开卡打印。
|
||||
* @param item 最新开卡记录
|
||||
* @param wallet 实际绑定钱包,缺失时为空
|
||||
* @param existingUsers 当前人员权限内实际存在的绑定用户
|
||||
* @return 中文拦截原因,卡片可继续处理时为空
|
||||
*/
|
||||
private String invalidReason(Item item, WalletManageEntity wallet, Set<Long> existingUsers) {
|
||||
if (item.getUserId() == null) {
|
||||
return "未绑定用户";
|
||||
}
|
||||
if (!existingUsers.contains(item.getUserId())) {
|
||||
return "绑定用户不存在或无权访问";
|
||||
}
|
||||
if (wallet == null) {
|
||||
return "绑定钱包不存在";
|
||||
}
|
||||
if (!Objects.equals(item.getUserId(), wallet.getUserId())
|
||||
|| !Objects.equals(item.getWalletTypeId(), wallet.getWalletTypeId())
|
||||
|| StrUtil.isBlank(item.getCardNumber()) || !Objects.equals(item.getCardNumber(), wallet.getCardNumber())) {
|
||||
return "饭卡与钱包绑定信息不一致";
|
||||
}
|
||||
if (YesNoEnum.YES.getCode().equals(wallet.getIsFreeze())) {
|
||||
return "钱包已冻结";
|
||||
}
|
||||
String status = wallet.getStatus();
|
||||
if (StrUtil.isNotBlank(status) && !WalleCardStatusEnum.ACTIVE.getCode().equals(status)
|
||||
&& !WalleCardStatusEnum.NONE_ACTIVE.getCode().equals(status)) {
|
||||
return "钱包状态异常(挂失、冻结、锁卡或销卡)";
|
||||
}
|
||||
if (wallet.getBalance() == null) {
|
||||
return "钱包余额异常";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param cardId 开卡记录主键
|
||||
* @return 永久对应该记录的开卡费业务号
|
||||
*/
|
||||
private String bizId(Long cardId) {
|
||||
return WalletOpenCardFeeConstants.BIZ_ID_PREFIX + cardId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param item 当前结果
|
||||
* @param transaction 该卡已有的首次成功收费流水
|
||||
*/
|
||||
private void fillCharged(Item item, WalletTransactionEntity transaction) {
|
||||
item.setTransactionId(transaction.getId());
|
||||
item.setChargedPrice(Math.negateExact(transaction.getPrice()));
|
||||
item.setChargePrice(0);
|
||||
item.setFeeState(State.CHARGED);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param item 需要拦截的卡片
|
||||
* @param reason 可直接在抽屉表格展示的原因
|
||||
*/
|
||||
private void block(Item item, String reason) {
|
||||
item.setFeeState(State.BLOCKED);
|
||||
item.setChargePrice(0);
|
||||
item.setReason(reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param result 逐卡结果已完成更新的批次,汇总只统计本次实际可执行的金额
|
||||
*/
|
||||
private void summarize(WalletOpenCardFeeVo result) {
|
||||
result.setPendingCount((int) result.getItems().stream().filter(item -> item.getFeeState() == State.PENDING).count());
|
||||
result.setChargedCount((int) result.getItems().stream().filter(item -> item.getFeeState() == State.CHARGED).count());
|
||||
result.setAbnormalCount((int) result.getItems().stream().filter(item -> item.getFeeState() == State.BLOCKED).count());
|
||||
result.setTotalPrice(result.getItems().stream().mapToLong(Item::getChargePrice).sum());
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1559,7 +1559,9 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
// 订单
|
||||
if (dto.getIsFindOrder()) {
|
||||
list.forEach(v -> {
|
||||
if (ObjectUtil.isNotEmpty(v.getBizId())) {
|
||||
// 开卡费借用消费流水记账,业务号不是订单,补打记录不能产生订单跳转或转换错误日志。
|
||||
if (ObjectUtil.isNotEmpty(v.getBizId())
|
||||
&& !v.getBizId().startsWith(com.bsd.pjcloud.wallet.api.constant.WalletOpenCardFeeConstants.BIZ_ID_PREFIX)) {
|
||||
try {
|
||||
v.setOrderId(Long.valueOf(v.getBizId()));
|
||||
} catch (Exception e) {
|
||||
|
||||
+88
-6
@@ -13,6 +13,8 @@ import com.bsd.pjcloud.admin.api.dto.UserInfo;
|
||||
import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum;
|
||||
import com.bsd.pjcloud.admin.api.constant.user.UserRelationTypeEnum;
|
||||
import com.bsd.pjcloud.admin.api.entity.SysDept;
|
||||
import com.bsd.pjcloud.admin.api.entity.SysDictItem;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteDictService;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteFileService;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
|
||||
@@ -28,6 +30,7 @@ import com.bsd.pjcloud.common.data.datascope.DataScope;
|
||||
import com.bsd.pjcloud.common.data.resolver.ParamResolver;
|
||||
import com.bsd.pjcloud.common.security.service.PjcloudUser;
|
||||
import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.utils.Drawing2D;
|
||||
import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdLbApiService;
|
||||
import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService;
|
||||
@@ -41,6 +44,8 @@ import com.bsd.pjcloud.wallet.api.dto.WalletTypeDto;
|
||||
import com.bsd.pjcloud.wallet.api.dto.mch.MchPaySettingDto;
|
||||
import com.bsd.pjcloud.wallet.api.dto.mch.WalletMchDeptViewDto;
|
||||
import com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity;
|
||||
import com.bsd.pjcloud.wallet.api.entity.mch.MchMealTimeConfig;
|
||||
import com.bsd.pjcloud.wallet.api.utils.MchMealTimeUtils;
|
||||
import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity;
|
||||
import com.bsd.pjcloud.wallet.api.utils.WalletDataUtil;
|
||||
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
|
||||
@@ -70,6 +75,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -96,6 +102,8 @@ public class MchManageServiceImpl extends ServiceImpl<MchManageMapper, MchManage
|
||||
|
||||
private final RemoteUserService remoteUserService;
|
||||
private final RemoteDeptService remoteDeptService;
|
||||
/** 定餐餐段保存时读取现有字典,校验编码并固定展示顺序。 */
|
||||
private final RemoteDictService remoteDictService;
|
||||
private final RemoteFileService remoteFileService;
|
||||
private final RemoteHardOrderService remoteHardOrderService;
|
||||
private final RemoterThirdLbApiService remoterThirdLbApiService;
|
||||
@@ -124,6 +132,7 @@ public class MchManageServiceImpl extends ServiceImpl<MchManageMapper, MchManage
|
||||
public Long create(MchManageEntity dto) {
|
||||
normalizeTableReservationConf(dto);
|
||||
normalizeTopUpConfig(dto);
|
||||
normalizeMealTimeConfig(dto, null);
|
||||
// 饭卡密码由后端统一兜底默认值,前端不感知
|
||||
if (StrUtil.isBlank(dto.getCardKey())) {
|
||||
dto.setCardKey(DEFAULT_CARD_KEY);
|
||||
@@ -157,14 +166,21 @@ public class MchManageServiceImpl extends ServiceImpl<MchManageMapper, MchManage
|
||||
return dto.getId();
|
||||
}
|
||||
|
||||
/** 编辑商户时锁定当前行,原子合并新旧时间配置和旧客户端的局部提交。 */
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
// @CacheEvict(value = CacheConstants.WALLET_MCH, key = "#dto.getId()", condition = "#dto.getId() != null")
|
||||
@CacheEvict(
|
||||
value = CacheConstants.WALLET_VIEWS_CANTEEN_MCH,
|
||||
allEntries = true,
|
||||
beforeInvocation = true
|
||||
allEntries = true
|
||||
)
|
||||
public void edit(MchManageEntity dto) {
|
||||
MchManageEntity mchManage = baseMapper.selectOne(new LambdaQueryWrapper<MchManageEntity>()
|
||||
.eq(MchManageEntity::getId, dto.getId()).last("FOR UPDATE"));
|
||||
if (mchManage == null) {
|
||||
throw new RuntimeException("商户不存在");
|
||||
}
|
||||
normalizeMealTimeConfig(dto, mchManage);
|
||||
normalizeTableReservationConf(dto);
|
||||
normalizeTopUpConfig(dto);
|
||||
// 饭卡密码:脱敏回显值或空值一律不更新,防止无权限界面把真实密码覆盖掉
|
||||
@@ -182,15 +198,11 @@ public class MchManageServiceImpl extends ServiceImpl<MchManageMapper, MchManage
|
||||
throw new RuntimeException("该类型不能选择品牌");
|
||||
}
|
||||
}
|
||||
MchManageEntity mchManage = getSelf().getById(dto.getId());
|
||||
Long orgParentId = mchManage.getParentId();
|
||||
if (ObjectUtil.isEmpty(mchManage.getParentId())) {
|
||||
mchManage.setParentId(-1L);
|
||||
dto.setParentId(-1L);
|
||||
}
|
||||
if (ObjectUtil.isEmpty(mchManage)) {
|
||||
throw new RuntimeException("商户不存在");
|
||||
}
|
||||
if (ObjectUtil.equals(mchManage.getTypes(), MerchantTypeEnum.SUPERMARKET.getType())) {
|
||||
if (ObjectUtil.isNotEmpty(mchManage.getMchCount()) && mchManage.getMchCount() > 0) {
|
||||
if (!ObjectUtil.equals(mchManage.getTypes(), dto.getTypes())) {
|
||||
@@ -209,6 +221,39 @@ public class MchManageServiceImpl extends ServiceImpl<MchManageMapper, MchManage
|
||||
// walletThirdZyService.syncAllMch(dto.getId(), Boolean.FALSE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 结构化配置优先;旧请求仅合并变化的逗号位置,未提交时间字段时保持数据库原值。
|
||||
* 字典已删除的历史项允许原样保留或清空,新建及变更的非空时段必须使用现有字典编码。
|
||||
*/
|
||||
private void normalizeMealTimeConfig(MchManageEntity dto, MchManageEntity existing) {
|
||||
if (dto.getMealTimeConfig() == null && (dto.getJobTime() == null
|
||||
|| existing != null && Objects.equals(dto.getJobTime(), existing.getJobTime()))) {
|
||||
dto.setJobTime(null);
|
||||
return;
|
||||
}
|
||||
MchMealTimeConfig before = MchMealTimeUtils.resolve(existing);
|
||||
MchMealTimeConfig submitted = dto.getMealTimeConfig() == null ? null : MchMealTimeUtils.normalize(dto.getMealTimeConfig());
|
||||
// 部分旧页面会透传完整详情,新字段原样回显时仍接受其明确修改的旧时间位置。
|
||||
boolean legacyChanged = dto.getJobTime() != null && (existing == null || !Objects.equals(dto.getJobTime(), existing.getJobTime()));
|
||||
MchMealTimeConfig next = submitted == null || legacyChanged && submitted.equals(MchMealTimeUtils.normalize(before))
|
||||
? MchMealTimeUtils.mergeLegacyUpdate(existing, dto.getJobTime()) : submitted;
|
||||
List<String> changedCodes = next.getPeriods().entrySet().stream()
|
||||
.filter(entry -> !entry.getValue().isEmpty() && !Objects.equals(entry.getValue(), before.getPeriods().get(entry.getKey())))
|
||||
.map(Map.Entry::getKey).toList();
|
||||
if (!changedCodes.isEmpty()) {
|
||||
R<List<SysDictItem>> result = remoteDictService.getDictByType("daily_menu");
|
||||
if (result == null || !result.isOk() || result.getData() == null) {
|
||||
throw new IllegalArgumentException("用餐时间的餐段字典读取失败,请重试");
|
||||
}
|
||||
List<String> validCodes = result.getData().stream().map(SysDictItem::getItemValue).toList();
|
||||
if (!validCodes.containsAll(changedCodes)) {
|
||||
throw new IllegalArgumentException("用餐时间包含无效餐段,请刷新后重新配置");
|
||||
}
|
||||
}
|
||||
dto.setMealTimeConfig(next);
|
||||
dto.setJobTime(MchMealTimeUtils.toLegacy(next));
|
||||
}
|
||||
|
||||
/**
|
||||
* 只规范化本次明确提交的 JSON 配置,避免其他局部更新接口覆盖数据库中的原配置。
|
||||
*
|
||||
@@ -219,6 +264,7 @@ public class MchManageServiceImpl extends ServiceImpl<MchManageMapper, MchManage
|
||||
if (conf == null) {
|
||||
return;
|
||||
}
|
||||
normalizeReservationHourTypes(dto, conf);
|
||||
String reserveMode = conf.getReserveMode();
|
||||
if (reserveMode == null || reserveMode.trim().isEmpty()) {
|
||||
conf.setReserveMode("TIME_WINDOW");
|
||||
@@ -251,6 +297,42 @@ public class MchManageServiceImpl extends ServiceImpl<MchManageMapper, MchManage
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验明确提交的订餐餐段并按字典排序。旧客户端缺少该属性时仅合并已有餐段,
|
||||
* 避免编辑其他配置把已启用餐段恢复成全部;历史商户仍保留 null 的兼容语义。
|
||||
*
|
||||
* @param dto 本次商户写入对象,编辑时包含商户 ID
|
||||
* @param conf 本次明确提交的定餐 JSON 配置
|
||||
*/
|
||||
private void normalizeReservationHourTypes(MchManageEntity dto, MchManageEntity.TableReservationConf conf) {
|
||||
if (conf.getHourTypes() == null) {
|
||||
if (dto.getId() != null) {
|
||||
MchManageEntity existing = baseMapper.selectById(dto.getId());
|
||||
if (existing != null && existing.getTableReservationConf() != null) {
|
||||
conf.setHourTypes(existing.getTableReservationConf().getHourTypes());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (conf.getHourTypes().isEmpty() || conf.getHourTypes().stream().anyMatch(StrUtil::isBlank)) {
|
||||
throw new RuntimeException("请至少选择一个有效订餐时间段");
|
||||
}
|
||||
R<List<SysDictItem>> result = remoteDictService.getDictByType("daily_menu");
|
||||
if (result == null || !result.isOk() || ObjectUtil.isEmpty(result.getData())) {
|
||||
throw new RuntimeException("订餐时间段字典读取失败,请重试");
|
||||
}
|
||||
List<String> selected = conf.getHourTypes().stream().map(String::trim).distinct().toList();
|
||||
List<String> ordered = result.getData().stream()
|
||||
.sorted(Comparator.comparing(SysDictItem::getSortOrder, Comparator.nullsLast(Comparator.naturalOrder())))
|
||||
.map(SysDictItem::getItemValue)
|
||||
.filter(code -> HardHourTypeEnum.valueOfByType(code) != null && selected.contains(code))
|
||||
.distinct().toList();
|
||||
if (ordered.size() != selected.size()) {
|
||||
throw new RuntimeException("订餐时间段包含无效餐段,请重新选择");
|
||||
}
|
||||
conf.setHourTypes(ordered);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化钱包充值配置中的身份协议编码,避免无效值写入商户 JSON 配置。
|
||||
* 充值配置为空时保持原样,以兼容历史商户及“空列表表示不限制身份”的约定。
|
||||
|
||||
+6
-3
@@ -14,6 +14,7 @@ import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteUserFaceService;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo;
|
||||
import com.bsd.pjcloud.admin.api.vo.UserVO;
|
||||
import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum;
|
||||
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
|
||||
import com.bsd.pjcloud.common.core.util.BeanUtils;
|
||||
import com.bsd.pjcloud.common.core.util.MsgUtils;
|
||||
@@ -183,8 +184,8 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
|
||||
private List<GuardianWardRechargeItemVo> listUserBusinessItems(List<Long> userIds, Set<Long> wardUserIds, Long currentUserId) {
|
||||
R<List<UserVO>> userResult;
|
||||
try {
|
||||
// user-dept 接口会一次补齐身份协议编码,充值资格不能依赖不含身份的 SysUser 接口。
|
||||
userResult = remoteUserService.getUserDeptByIds(userIds);
|
||||
// 展示列表按需批量读取学生资料,其他充值校验继续使用只带身份编码的旧调用。
|
||||
userResult = remoteUserService.getUserDeptByIds(userIds, true);
|
||||
} catch (Exception exception) {
|
||||
throw new RuntimeException("查询关系人信息失败", exception);
|
||||
}
|
||||
@@ -620,7 +621,7 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造移动端可展示的脱敏饭卡摘要,不返回钱包 ID 或完整卡号。
|
||||
* 构造移动端学生资料与脱敏饭卡摘要,不返回钱包 ID 或完整卡号。
|
||||
*/
|
||||
private GuardianWardRechargeItemVo buildWardItem(UserVO ward, WalletManageEntity wallet) {
|
||||
GuardianWardRechargeItemVo item = new GuardianWardRechargeItemVo();
|
||||
@@ -628,6 +629,8 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
|
||||
item.setWardName(displayName(ward));
|
||||
item.setWardAvatar(ward == null ? null : ward.getAvatar());
|
||||
item.setIdentityTypes(ward == null || ward.getIdentityTypes() == null ? List.of() : List.copyOf(ward.getIdentityTypes()));
|
||||
// 学生可能尚未开通饭卡,必须在钱包空值分支前保留其身份资料。
|
||||
item.setStudentInfo(ward != null && item.getIdentityTypes().contains(UserIdentityTypeEnum.STUDENT.getType()) ? ward.getStudentInfo() : null);
|
||||
if (wallet == null) {
|
||||
item.setWalletOpened(false);
|
||||
item.setBalance(0);
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
<mapper namespace="com.bsd.pjcloud.wallet.mapper.MchManageMapper">
|
||||
|
||||
<!--
|
||||
自定义 XML 查询不会复用 MchManageEntity 的 autoResultMap,四个 JSON 字段必须显式指定处理器。
|
||||
自定义 XML 查询不会复用 MchManageEntity 的 autoResultMap,JSON 字段必须显式指定处理器。
|
||||
其余同名字段继续由 MyBatis 驼峰规则自动映射到 MchManageVo(包括继承自实体的属性)。
|
||||
-->
|
||||
<resultMap id="mchManageVoMap" type="com.bsd.pjcloud.wallet.api.vo.MchManageVo" autoMapping="true">
|
||||
<result column="meal_time_config" property="mealTimeConfig"
|
||||
javaType="com.bsd.pjcloud.wallet.api.entity.mch.MchMealTimeConfig"
|
||||
typeHandler="com.bsd.pjcloud.wallet.api.handler.MchManageJacksonTypeHandler"/>
|
||||
<result column="function_scope" property="functionScope"
|
||||
javaType="com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity$FunctionScope"
|
||||
typeHandler="com.bsd.pjcloud.wallet.api.handler.MchManageJacksonTypeHandler"/>
|
||||
@@ -38,6 +41,7 @@
|
||||
a.phone,
|
||||
a.address_des,
|
||||
a.job_time,
|
||||
a.meal_time_config,
|
||||
a.is_sync_zy,
|
||||
a.visible_mobile,
|
||||
a.sort,
|
||||
|
||||
+13
@@ -1,6 +1,7 @@
|
||||
package com.bsd.pjcloud.wallet.api.handler;
|
||||
|
||||
import com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity;
|
||||
import com.bsd.pjcloud.wallet.api.entity.mch.MchMealTimeConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
@@ -42,4 +43,16 @@ class MchManageJacksonTypeHandlerTest {
|
||||
assertEquals(Boolean.TRUE, functionScope.getMallEnable());
|
||||
assertEquals(Boolean.FALSE, functionScope.getTableReservation());
|
||||
}
|
||||
/** XML 具体包装类必须保留餐段映射中的时段元素类型,等时刻也能完整往返。 */
|
||||
@Test
|
||||
void mealTimeConfigShouldDeserializeNestedPeriodTypes() {
|
||||
MchManageJacksonTypeHandler handler = new MchManageJacksonTypeHandler(MchMealTimeConfig.class);
|
||||
String json = "{\"periods\":{\"10\":[{\"startTime\":\"11:18\",\"endTime\":\"11:18\",\"endDayOffset\":0}]}}";
|
||||
MchMealTimeConfig config = assertInstanceOf(MchMealTimeConfig.class, handler.parse(json));
|
||||
MchMealTimeConfig.Period period = assertInstanceOf(MchMealTimeConfig.Period.class, config.getPeriods().get("10").get(0));
|
||||
assertEquals("11:18", period.getStartTime());
|
||||
assertEquals(0, period.getEndDayOffset());
|
||||
assertEquals(config, handler.parse(handler.toJson(config)));
|
||||
}
|
||||
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package com.bsd.pjcloud.wallet.api.utils;
|
||||
|
||||
import com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity;
|
||||
import com.bsd.pjcloud.wallet.api.entity.mch.MchMealTimeConfig;
|
||||
import com.bsd.pjcloud.wallet.api.entity.mch.MchMealTimeConfig.Period;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** 用餐时间重构的兼容性回归,覆盖等时刻、稀疏编码、多时段及旧客户端合并。 */
|
||||
class MchMealTimeUtilsTest {
|
||||
|
||||
/** 旧餐段空位必须保留身份,必选餐等时刻按时间点读取,非零秒精度保留。 */
|
||||
@Test
|
||||
void sparseLegacySlotsPreserveCodesAndTimePoints() {
|
||||
MchMealTimeConfig config = MchMealTimeUtils.fromLegacy(",,,,,,,,,11:18-11:18,10:18:09-11:18:00");
|
||||
assertEquals(List.of("10", "11"), config.getPeriods().keySet().stream().toList());
|
||||
assertEquals(period("11:18", "11:18", 0), config.getPeriods().get("10").get(0));
|
||||
assertEquals(period("10:18:09", "11:18", 0), config.getPeriods().get("11").get(0));
|
||||
}
|
||||
|
||||
/** 新配置明确清空时,不得从仍存在的兼容旧串复活已删除的时间。 */
|
||||
@Test
|
||||
void explicitEmptyConfigIsAuthoritative() {
|
||||
MchManageEntity merchant = new MchManageEntity();
|
||||
merchant.setJobTime("07:00-09:00");
|
||||
merchant.setMealTimeConfig(new MchMealTimeConfig());
|
||||
assertTrue(MchMealTimeUtils.resolve(merchant).getPeriods().isEmpty());
|
||||
}
|
||||
|
||||
/** 旧客户端原样回写首时段时,同餐段额外时段及字符串编码均保持完整。 */
|
||||
@Test
|
||||
void unchangedLegacyProjectionPreservesAllPeriods() {
|
||||
MchManageEntity merchant = merchantWithExtendedTimes();
|
||||
MchMealTimeConfig result = MchMealTimeUtils.mergeLegacyUpdate(merchant, merchant.getJobTime());
|
||||
assertEquals(merchant.getMealTimeConfig(), result);
|
||||
assertEquals(2, result.getPeriods().get("2").size());
|
||||
assertTrue(result.getPeriods().containsKey("custom_meal"));
|
||||
}
|
||||
|
||||
/** 旧四餐页面只改早餐,不应截断编码 10、11,也不应删除未改变午餐的第二时段。 */
|
||||
@Test
|
||||
void truncatedLegacyUpdateOnlyChangesProvidedPositions() {
|
||||
MchManageEntity merchant = merchantWithExtendedTimes();
|
||||
MchMealTimeConfig result = MchMealTimeUtils.mergeLegacyUpdate(merchant, "07:15-09:00,11:00-12:00,,");
|
||||
assertEquals(period("07:15", "09:00", 0), result.getPeriods().get("1").get(0));
|
||||
assertEquals(merchant.getMealTimeConfig().getPeriods().get("2"), result.getPeriods().get("2"));
|
||||
assertEquals(merchant.getMealTimeConfig().getPeriods().get("10"), result.getPeriods().get("10"));
|
||||
assertEquals(merchant.getMealTimeConfig().getPeriods().get("11"), result.getPeriods().get("11"));
|
||||
}
|
||||
|
||||
/** 旧请求明确清空午餐时只删除该餐段,其余隐藏时间仍能重新启用。 */
|
||||
@Test
|
||||
void legacyClearOnlyRemovesTheChangedMeal() {
|
||||
MchManageEntity merchant = merchantWithExtendedTimes();
|
||||
MchMealTimeConfig result = MchMealTimeUtils.mergeLegacyUpdate(merchant, "07:00-09:00,,,");
|
||||
assertFalse(result.getPeriods().containsKey("2"));
|
||||
assertTrue(result.getPeriods().containsKey("10"));
|
||||
}
|
||||
|
||||
/** 历史跨午夜区间推断次日,配送读取完整启用时段,并保留原配置中的停用餐段。 */
|
||||
@Test
|
||||
void crossDayTimesHaveExplicitOffsetAndRespectEnabledMeals() {
|
||||
MchManageEntity merchant = new MchManageEntity();
|
||||
merchant.setJobTime("23:00-01:00,11:18-11:18");
|
||||
MchManageEntity.TableReservationConf reservation = new MchManageEntity.TableReservationConf();
|
||||
reservation.setHourTypes(List.of("1"));
|
||||
merchant.setTableReservationConf(reservation);
|
||||
assertEquals(1, MchMealTimeUtils.resolve(merchant).getPeriods().get("1").get(0).getEndDayOffset());
|
||||
assertEquals("00:00-01:00,23:00-23:59:59", MchMealTimeUtils.toDailyIntervals(merchant));
|
||||
assertTrue(MchMealTimeUtils.resolve(merchant).getPeriods().containsKey("2"));
|
||||
}
|
||||
|
||||
/** 新保存数据拒绝非法时刻、倒序当天区间及协议范围外的结束日偏移。 */
|
||||
@Test
|
||||
void invalidNewPeriodsAreRejected() {
|
||||
assertThrows(IllegalArgumentException.class, () -> MchMealTimeUtils.normalizePeriod(period("24:00", "12:00", 0)));
|
||||
assertThrows(IllegalArgumentException.class, () -> MchMealTimeUtils.normalizePeriod(period("11:60", "12:00", 0)));
|
||||
assertThrows(IllegalArgumentException.class, () -> MchMealTimeUtils.normalizePeriod(period("23:00", "01:00", 0)));
|
||||
assertThrows(IllegalArgumentException.class, () -> MchMealTimeUtils.normalizePeriod(period("11:00", "12:00", 2)));
|
||||
assertEquals(1, MchMealTimeUtils.normalizePeriod(period("11:00", "12:00", 1)).getEndDayOffset());
|
||||
assertEquals(period("11:18", "11:18", 0), MchMealTimeUtils.normalizePeriod(period("11:18:00", "11:18:00", 0)));
|
||||
}
|
||||
|
||||
/** 非数字或很大编码不受旧字段容量约束,也不产生稀疏数组的大量分配。 */
|
||||
@Test
|
||||
void structuredCodesAndPeriodsSurviveBoundedLegacyProjection() {
|
||||
MchManageEntity merchant = merchantWithExtendedTimes();
|
||||
merchant.getMealTimeConfig().getPeriods().put("9999999999999999", List.of(period("12:00", "13:00", 0)));
|
||||
String legacy = MchMealTimeUtils.toLegacy(merchant.getMealTimeConfig());
|
||||
assertTrue(legacy.length() <= 256);
|
||||
assertEquals("11:18-11:18", legacy.split(",", -1)[9]);
|
||||
assertEquals(2, merchant.getMealTimeConfig().getPeriods().get("2").size());
|
||||
assertTrue(merchant.getMealTimeConfig().getPeriods().containsKey("custom_meal"));
|
||||
}
|
||||
|
||||
/** 构造同时包含原四餐、自定义字符串餐段和编码 10、11 的完整商户时间。 */
|
||||
private static MchManageEntity merchantWithExtendedTimes() {
|
||||
MchMealTimeConfig config = new MchMealTimeConfig();
|
||||
config.getPeriods().put("1", List.of(period("07:00", "09:00", 0)));
|
||||
config.getPeriods().put("2", List.of(period("11:00", "12:00", 0), period("12:30", "13:00", 0)));
|
||||
config.getPeriods().put("10", List.of(period("11:18", "11:18", 0)));
|
||||
config.getPeriods().put("11", List.of(period("10:18", "11:18", 0)));
|
||||
config.getPeriods().put("custom_meal", List.of(period("15:00", "16:00", 0)));
|
||||
MchManageEntity merchant = new MchManageEntity();
|
||||
merchant.setMealTimeConfig(config);
|
||||
merchant.setJobTime(MchMealTimeUtils.toLegacy(config));
|
||||
return merchant;
|
||||
}
|
||||
|
||||
/** 创建测试时段,参数沿用接口的当地时刻和结束日偏移语义。 */
|
||||
private static Period period(String start, String end, int offset) {
|
||||
Period period = new Period();
|
||||
period.setStartTime(start);
|
||||
period.setEndTime(end);
|
||||
period.setEndDayOffset(offset);
|
||||
return period;
|
||||
}
|
||||
}
|
||||
+447
@@ -0,0 +1,447 @@
|
||||
package com.bsd.pjcloud.wallet.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.config.GlobalConfig;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScopeInnerInterceptor;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScopeSqlInjector;
|
||||
import com.bsd.pjcloud.common.data.mybatis.MybatisPlusMetaObjectHandler;
|
||||
import com.bsd.pjcloud.common.data.tenant.PjcloudTenantConfigProperties;
|
||||
import com.bsd.pjcloud.common.data.tenant.PjcloudTenantHandler;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
|
||||
import com.bsd.pjcloud.wallet.api.dto.WalletOpenCardFeeDto;
|
||||
import com.bsd.pjcloud.wallet.api.entity.*;
|
||||
import com.bsd.pjcloud.wallet.api.vo.WalletOpenCardFeeVo;
|
||||
import com.bsd.pjcloud.wallet.mapper.*;
|
||||
import com.bsd.pjcloud.wallet.service.MchManageService;
|
||||
import com.bsd.pjcloud.wallet.service.WalletOpenCardFeeService;
|
||||
import com.bsd.pjcloud.wallet.service.WalletTransactionService;
|
||||
import com.bsd.pjcloud.wallet.support.WalletUserIdentitySupport;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.redisson.Redisson;
|
||||
import org.redisson.api.RBucket;
|
||||
import org.redisson.api.RLock;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 使用独立 MySQL 测试库验证真实行锁、事务、租户隔离及收费流水落库,不连接业务库。 */
|
||||
@EnabledIfEnvironmentVariable(named = "OPEN_CARD_FEE_TEST_JDBC_URL", matches = "jdbc:mysql://(127\\.0\\.0\\.1|localhost):[0-9]+/.*_open_card_fee_test.*")
|
||||
class WalletOpenCardFeeDatabaseTest {
|
||||
|
||||
/** 仅允许本轮专用的四张测试表,创建和清空前必须通过独立测试库后缀校验。 */
|
||||
private static final List<Class<?>> ENTITIES = List.of(WalletActiveEntity.class, WalletManageEntity.class,
|
||||
WalletTransactionEntity.class, WalletMessageOutboxEntity.class);
|
||||
|
||||
/** 仅用于独立测试库的 JDBC 查询,不携带业务库凭据。 */
|
||||
private static JdbcTemplate jdbc;
|
||||
|
||||
/** 使用真实 Mapper 和 Spring 事务代理的被测开卡费服务。 */
|
||||
private static WalletOpenCardFeeService service;
|
||||
|
||||
/** 只有独立测试库校验成功后才允许测试夹具执行建表和清空。 */
|
||||
private static boolean isolated;
|
||||
|
||||
/** 构造实际 MyBatis-Plus、租户和数据权限拦截器,资金写入使用项目真实扣款与 Outbox 实现。 */
|
||||
@BeforeAll
|
||||
static void initialize() throws Exception {
|
||||
String url = System.getenv("OPEN_CARD_FEE_TEST_JDBC_URL");
|
||||
DriverManagerDataSource dataSource = new DriverManagerDataSource(url,
|
||||
Objects.toString(System.getenv("OPEN_CARD_FEE_TEST_JDBC_USER"), "root"),
|
||||
Objects.toString(System.getenv("OPEN_CARD_FEE_TEST_JDBC_PASSWORD"), ""));
|
||||
try (var connection = dataSource.getConnection()) {
|
||||
assertTrue(connection.getCatalog().endsWith("_open_card_fee_test"));
|
||||
isolated = true;
|
||||
}
|
||||
jdbc = new JdbcTemplate(dataSource);
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
// 与聚合服务现有配置一致,避免 SQL 解析器把动态 SQL 的连续空行误认为语句结束。
|
||||
configuration.setShrinkWhitespacesInSql(true);
|
||||
MybatisPlusInterceptor interceptors = new MybatisPlusInterceptor();
|
||||
interceptors.addInnerInterceptor(new TenantLineInnerInterceptor(new PjcloudTenantHandler(new PjcloudTenantConfigProperties())));
|
||||
DataScopeInnerInterceptor scope = new DataScopeInnerInterceptor();
|
||||
scope.setDataScopeHandle(dataScope -> {
|
||||
dataScope.setDeptList(List.of(501L));
|
||||
return false;
|
||||
});
|
||||
interceptors.addInnerInterceptor(scope);
|
||||
MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(dataSource);
|
||||
factory.setConfiguration(configuration);
|
||||
factory.setGlobalConfig(new GlobalConfig().setSqlInjector(new DataScopeSqlInjector())
|
||||
.setMetaObjectHandler(new MybatisPlusMetaObjectHandler()));
|
||||
factory.setPlugins(interceptors);
|
||||
var sqlSessionFactory = factory.getObject();
|
||||
configuration.addMapper(WalletActiveMapper.class);
|
||||
configuration.addMapper(WalletManageMapper.class);
|
||||
configuration.addMapper(WalletTransactionMapper.class);
|
||||
configuration.addMapper(WalletMessageOutboxMapper.class);
|
||||
SqlSessionTemplate sessions = new SqlSessionTemplate(sqlSessionFactory);
|
||||
for (Class<?> entity : ENTITIES) createTable(entity);
|
||||
jdbc.execute("CREATE INDEX idx_fee_biz ON wallet_transaction (tenant_id, biz_id(80))");
|
||||
|
||||
Redisson redisson = mock(Redisson.class);
|
||||
RLock operationLock = mock(RLock.class);
|
||||
when(operationLock.tryLock(anyLong(), any(TimeUnit.class))).thenReturn(true);
|
||||
when(operationLock.isHeldByCurrentThread()).thenReturn(true);
|
||||
when(redisson.getLock(anyString())).thenReturn(operationLock);
|
||||
RBucket<Object> bucket = mock(RBucket.class);
|
||||
when(redisson.getBucket(anyString())).thenReturn(bucket);
|
||||
WalletTypeMapper types = mock(WalletTypeMapper.class);
|
||||
WalletTransactionServiceImpl transaction = mock(WalletTransactionServiceImpl.class, CALLS_REAL_METHODS);
|
||||
ReflectionTestUtils.setField(transaction, "baseMapper", sessions.getMapper(WalletTransactionMapper.class));
|
||||
ReflectionTestUtils.setField(transaction, "walletManageMapper", sessions.getMapper(WalletManageMapper.class));
|
||||
ReflectionTestUtils.setField(transaction, "walletMessageOutboxMapper", sessions.getMapper(WalletMessageOutboxMapper.class));
|
||||
ReflectionTestUtils.setField(transaction, "walletTypeMapper", types);
|
||||
ReflectionTestUtils.setField(transaction, "mchManageService", mock(MchManageService.class));
|
||||
ReflectionTestUtils.setField(transaction, "redisson", redisson);
|
||||
DataSourceTransactionManager transactions = new DataSourceTransactionManager(dataSource);
|
||||
WalletTransactionService transactionService = transactional(transaction, WalletTransactionService.class, transactions);
|
||||
WalletUserIdentitySupport users = mock(WalletUserIdentitySupport.class);
|
||||
when(users.findIdentityUserIds(any(), isNull(), anyList())).thenAnswer(invocation -> invocation.getArgument(2));
|
||||
WalletOpenCardFeeServiceImpl target = new WalletOpenCardFeeServiceImpl(sessions.getMapper(WalletActiveMapper.class),
|
||||
sessions.getMapper(WalletManageMapper.class), sessions.getMapper(WalletTransactionMapper.class), transactionService, users);
|
||||
service = transactional(target, WalletOpenCardFeeService.class, transactions);
|
||||
}
|
||||
|
||||
/** 每个用例重置独立夹具数据,并恢复普通租户上下文,避免前一用例影响后续结果。 */
|
||||
@BeforeEach
|
||||
void resetFixture() {
|
||||
assertTrue(isolated);
|
||||
jdbc.execute("DROP TRIGGER IF EXISTS reject_test_outbox");
|
||||
jdbc.execute("DROP TRIGGER IF EXISTS reject_test_transaction");
|
||||
for (Class<?> entity : ENTITIES) jdbc.execute("DELETE FROM " + TableInfoHelper.getTableInfo(entity).getTableName());
|
||||
TenantContextHolder.setTenantId(1L);
|
||||
}
|
||||
|
||||
/** 无论断言是否成功均清理当前测试线程的租户上下文。 */
|
||||
@AfterEach
|
||||
void clearContext() {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
/** 正常扣款必须同时改变余额和累计支出,流水及消息备注均使用实际开卡金额。 */
|
||||
@Test
|
||||
void persistsExpenseAndBothRemarksInSingleTransaction() {
|
||||
seed(101, 201, 1, 501, 5000);
|
||||
|
||||
assertEquals(1, service.charge(request(1550, 101)).getChargedCount());
|
||||
|
||||
assertEquals(3450, scalar("SELECT balance FROM wallet_manage WHERE id=201"));
|
||||
assertEquals(1550, scalar("SELECT total_expense FROM wallet_manage WHERE id=201"));
|
||||
assertEquals(5000, scalar("SELECT total_recharge FROM wallet_manage WHERE id=201"));
|
||||
var row = jdbc.queryForMap("SELECT * FROM wallet_transaction");
|
||||
assertEquals(-1550, ((Number) row.get("price")).intValue());
|
||||
assertEquals("4", row.get("biz_type"));
|
||||
assertEquals("3", row.get("tags"));
|
||||
assertEquals("OPEN_CARD:101", row.get("biz_id"));
|
||||
assertEquals("开卡费用15.5元", row.get("remark"));
|
||||
assertEquals(row.get("remark"), row.get("sys_remark"));
|
||||
assertNull(row.get("hard_type"));
|
||||
assertEquals(1, scalar("SELECT COUNT(*) FROM wallet_message_outbox"));
|
||||
assertEquals("开卡费用15.5元", jdbc.queryForObject("SELECT remark FROM wallet_message_outbox", String.class));
|
||||
}
|
||||
|
||||
/** 余额恰好等于含两位小数的开卡费时允许扣到零,流水余额必须与实际余额一致。 */
|
||||
@Test
|
||||
void exactBalanceWithTwoDecimalFeeCanBeConsumed() {
|
||||
seed(101, 201, 1, 501, 1529);
|
||||
|
||||
assertEquals(1, service.charge(request(1529, 101)).getChargedCount());
|
||||
|
||||
assertEquals(0, scalar("SELECT balance FROM wallet_manage WHERE id=201"));
|
||||
assertEquals(0, scalar("SELECT balance FROM wallet_transaction"));
|
||||
assertEquals("开卡费用15.29元", jdbc.queryForObject("SELECT sys_remark FROM wallet_transaction", String.class));
|
||||
}
|
||||
|
||||
/** 同钱包的多条历史开卡记录按合计预检,充足时逐条保存真实扣后余额。 */
|
||||
@Test
|
||||
void sharedWalletUsesCombinedFeeAndRunningBalances() {
|
||||
seed(101, 201, 1, 501, 2500);
|
||||
jdbc.update("INSERT INTO wallet_active (id,user_id,wallet_id,wallet_type_id,dept_id,card_number,is_active_hardware,tenant_id) "
|
||||
+ "SELECT 102,user_id,wallet_id,wallet_type_id,dept_id,card_number,is_active_hardware,tenant_id FROM wallet_active WHERE id=101");
|
||||
|
||||
assertEquals(2, service.charge(request(1500, 101, 102)).getAbnormalCount());
|
||||
assertEquals(2500, scalar("SELECT balance FROM wallet_manage WHERE id=201"));
|
||||
assertEquals(0, scalar("SELECT COUNT(*) FROM wallet_transaction"));
|
||||
jdbc.update("UPDATE wallet_manage SET balance=5000 WHERE id=201");
|
||||
|
||||
assertEquals(2, service.charge(request(1500, 101, 102)).getChargedCount());
|
||||
|
||||
assertEquals(2000, scalar("SELECT balance FROM wallet_manage WHERE id=201"));
|
||||
assertEquals(3000, scalar("SELECT total_expense FROM wallet_manage WHERE id=201"));
|
||||
assertEquals(List.of(3500, 2000), jdbc.queryForList("SELECT balance FROM wallet_transaction ORDER BY id", Integer.class));
|
||||
}
|
||||
|
||||
/** 同一卡的十二个并发请求必须全部返回已收费,数据库最终只能存在一次扣款。 */
|
||||
@Test
|
||||
void concurrentRequestsChargeCardOnce() throws Exception {
|
||||
seed(101, 201, 1, 501, 5000);
|
||||
ExecutorService workers = Executors.newFixedThreadPool(6);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
List<Future<WalletOpenCardFeeVo>> calls = new ArrayList<>();
|
||||
try {
|
||||
for (int i = 0; i < 12; i++) calls.add(workers.submit(() -> {
|
||||
TenantContextHolder.setTenantId(1L);
|
||||
try {
|
||||
start.await();
|
||||
return service.charge(request(1500, 101));
|
||||
} finally {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
}));
|
||||
start.countDown();
|
||||
for (Future<WalletOpenCardFeeVo> call : calls) assertEquals(1, call.get(30, TimeUnit.SECONDS).getChargedCount());
|
||||
} finally {
|
||||
workers.shutdownNow();
|
||||
}
|
||||
assertEquals(3500, scalar("SELECT balance FROM wallet_manage WHERE id=201"));
|
||||
assertEquals(1500, scalar("SELECT total_expense FROM wallet_manage WHERE id=201"));
|
||||
assertEquals(1, scalar("SELECT COUNT(*) FROM wallet_transaction"));
|
||||
assertEquals(1, scalar("SELECT COUNT(*) FROM wallet_message_outbox"));
|
||||
}
|
||||
|
||||
/** 同一批卡以相反顺序并发提交时,固定行锁顺序应避免死锁且每卡只收费一次。 */
|
||||
@Test
|
||||
void reversedConcurrentBatchesUseStableLockOrder() throws Exception {
|
||||
seed(101, 201, 1, 501, 5000);
|
||||
seed(102, 202, 1, 501, 5000);
|
||||
ExecutorService workers = Executors.newFixedThreadPool(4);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
List<Future<WalletOpenCardFeeVo>> calls = new ArrayList<>();
|
||||
try {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
long[] ids = i % 2 == 0 ? new long[] {101, 102} : new long[] {102, 101};
|
||||
calls.add(workers.submit(() -> {
|
||||
TenantContextHolder.setTenantId(1L);
|
||||
try {
|
||||
start.await();
|
||||
return service.charge(request(1500, ids));
|
||||
} finally {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
}));
|
||||
}
|
||||
start.countDown();
|
||||
for (Future<WalletOpenCardFeeVo> call : calls) assertEquals(2, call.get(30, TimeUnit.SECONDS).getChargedCount());
|
||||
} finally {
|
||||
workers.shutdownNow();
|
||||
}
|
||||
assertEquals(7000, scalar("SELECT SUM(balance) FROM wallet_manage"));
|
||||
assertEquals(2, scalar("SELECT COUNT(*) FROM wallet_transaction"));
|
||||
assertEquals(2, scalar("SELECT COUNT(*) FROM wallet_message_outbox"));
|
||||
}
|
||||
|
||||
/** 扣款响应丢失后重新发起操作,即使金额已改为其他值,也只复用原收费流水。 */
|
||||
@Test
|
||||
void retryAfterLostResponseKeepsOriginalCharge() {
|
||||
seed(101, 201, 1, 501, 5000);
|
||||
service.charge(request(1500, 101));
|
||||
|
||||
WalletOpenCardFeeVo repeated = service.charge(request(3200, 101));
|
||||
|
||||
assertEquals(1500, repeated.getItems().get(0).getChargedPrice());
|
||||
assertEquals(0, repeated.getTotalPrice());
|
||||
assertEquals(3500, scalar("SELECT balance FROM wallet_manage WHERE id=201"));
|
||||
assertEquals(1, scalar("SELECT COUNT(*) FROM wallet_transaction"));
|
||||
}
|
||||
|
||||
/** 第二张卡对应的消息写入失败时,第一张卡的流水、消息及两人的余额必须全部回滚。 */
|
||||
@Test
|
||||
void outboxFailureRollsBackWholeBatch() {
|
||||
seed(101, 201, 1, 501, 5000);
|
||||
seed(102, 202, 1, 501, 5000);
|
||||
jdbc.execute("CREATE TRIGGER reject_test_outbox BEFORE INSERT ON wallet_message_outbox FOR EACH ROW "
|
||||
+ "BEGIN IF NEW.wallet_id=202 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='simulated outbox failure'; END IF; END");
|
||||
|
||||
RuntimeException failure = assertThrows(RuntimeException.class, () -> service.charge(request(1500, 101, 102)));
|
||||
assertTrue(failure.getMessage().contains("simulated outbox failure"));
|
||||
|
||||
assertEquals(10000, scalar("SELECT SUM(balance) FROM wallet_manage"));
|
||||
assertEquals(0, scalar("SELECT SUM(total_expense) FROM wallet_manage"));
|
||||
assertEquals(0, scalar("SELECT COUNT(*) FROM wallet_transaction"));
|
||||
assertEquals(0, scalar("SELECT COUNT(*) FROM wallet_message_outbox"));
|
||||
}
|
||||
|
||||
/** 第二笔收费流水写入失败时,前一笔流水及全部钱包变动必须一起回滚。 */
|
||||
@Test
|
||||
void transactionInsertFailureRollsBackWholeBatch() {
|
||||
seed(101, 201, 1, 501, 5000);
|
||||
seed(102, 202, 1, 501, 5000);
|
||||
jdbc.execute("CREATE TRIGGER reject_test_transaction BEFORE INSERT ON wallet_transaction FOR EACH ROW "
|
||||
+ "BEGIN IF NEW.wallet_id=202 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='simulated transaction failure'; END IF; END");
|
||||
|
||||
RuntimeException failure = assertThrows(RuntimeException.class, () -> service.charge(request(1500, 101, 102)));
|
||||
assertTrue(failure.getMessage().contains("simulated transaction failure"));
|
||||
|
||||
assertEquals(10000, scalar("SELECT SUM(balance) FROM wallet_manage"));
|
||||
assertEquals(0, scalar("SELECT SUM(total_expense) FROM wallet_manage"));
|
||||
assertEquals(0, scalar("SELECT COUNT(*) FROM wallet_transaction"));
|
||||
assertEquals(0, scalar("SELECT COUNT(*) FROM wallet_message_outbox"));
|
||||
}
|
||||
|
||||
/** 整批中一人余额不足时,其他余额正常的卡片也不能留下部分收费。 */
|
||||
@Test
|
||||
void insufficientCardStopsAllWalletChanges() {
|
||||
seed(101, 201, 1, 501, 5000);
|
||||
seed(102, 202, 1, 501, 1000);
|
||||
|
||||
assertEquals(1, service.charge(request(1500, 101, 102)).getAbnormalCount());
|
||||
|
||||
assertEquals(6000, scalar("SELECT SUM(balance) FROM wallet_manage"));
|
||||
assertEquals(0, scalar("SELECT COUNT(*) FROM wallet_transaction"));
|
||||
}
|
||||
|
||||
/** 预检后其他消费已经扣走余额,真正扣款时重新读取余额并停止整个批次。 */
|
||||
@Test
|
||||
void balanceChangedAfterPreviewIsRechecked() {
|
||||
seed(101, 201, 1, 501, 5000);
|
||||
assertEquals(1, service.preview(request(1500, 101)).getPendingCount());
|
||||
jdbc.update("UPDATE wallet_manage SET balance=100 WHERE id=201");
|
||||
|
||||
assertEquals(1, service.charge(request(1500, 101)).getAbnormalCount());
|
||||
|
||||
assertEquals(100, scalar("SELECT balance FROM wallet_manage WHERE id=201"));
|
||||
assertEquals(0, scalar("SELECT COUNT(*) FROM wallet_transaction"));
|
||||
}
|
||||
|
||||
/** 预检后卡号被修改时,旧快照不能向新绑定继续收费。 */
|
||||
@Test
|
||||
void changedCardNumberStopsCharge() {
|
||||
seed(101, 201, 1, 501, 5000);
|
||||
WalletOpenCardFeeDto dto = request(1500, 101);
|
||||
jdbc.update("UPDATE wallet_active SET card_number='changed' WHERE id=101");
|
||||
|
||||
assertTrue(service.charge(dto).getItems().get(0).getReason().contains("已变化"));
|
||||
|
||||
assertEquals(5000, scalar("SELECT balance FROM wallet_manage WHERE id=201"));
|
||||
assertEquals(0, scalar("SELECT COUNT(*) FROM wallet_transaction"));
|
||||
}
|
||||
|
||||
/** 实际租户拦截器必须隐藏其他租户卡片,并阻止整批中正常卡片被部分扣费。 */
|
||||
@Test
|
||||
void tenantIsolationRejectsForeignCard() {
|
||||
seed(101, 201, 1, 501, 5000);
|
||||
seed(102, 202, 2, 501, 5000);
|
||||
|
||||
WalletOpenCardFeeVo result = service.charge(request(1500, 101, 102));
|
||||
|
||||
assertEquals(1, result.getAbnormalCount());
|
||||
assertNull(result.getItems().get(1).getCardNumber());
|
||||
assertEquals(10000, scalar("SELECT SUM(balance) FROM wallet_manage"));
|
||||
assertEquals(0, scalar("SELECT COUNT(*) FROM wallet_transaction"));
|
||||
}
|
||||
|
||||
/** 实际部门数据权限拦截器必须排除未授权部门的卡片。 */
|
||||
@Test
|
||||
void departmentScopeRejectsForeignCard() {
|
||||
seed(101, 201, 1, 501, 5000);
|
||||
seed(102, 202, 1, 502, 5000);
|
||||
|
||||
WalletOpenCardFeeVo result = service.charge(request(1500, 101, 102));
|
||||
|
||||
assertEquals(1, result.getAbnormalCount());
|
||||
assertNull(result.getItems().get(1).getCardNumber());
|
||||
assertEquals(0, scalar("SELECT COUNT(*) FROM wallet_transaction"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据实体元数据建立隔离夹具表,避免手写漏列导致 ORM 查询和实际业务路径不同。
|
||||
* @param entity 本轮允许使用的四种钱包实体之一
|
||||
*/
|
||||
private static void createTable(Class<?> entity) {
|
||||
assertTrue(isolated);
|
||||
var table = TableInfoHelper.getTableInfo(entity);
|
||||
List<String> columns = new ArrayList<>();
|
||||
columns.add("`" + table.getKeyColumn() + "` BIGINT PRIMARY KEY");
|
||||
for (var field : table.getFieldList()) {
|
||||
String type = field.getPropertyType() == Long.class ? "BIGINT" : field.getPropertyType() == Integer.class ? "INT"
|
||||
: field.getPropertyType() == LocalDateTime.class ? "DATETIME" : "VARCHAR(512)";
|
||||
if (field.getColumn().equals("del_flag")) type = "VARCHAR(2) NOT NULL DEFAULT '0'";
|
||||
columns.add("`" + field.getColumn() + "` " + type);
|
||||
}
|
||||
jdbc.execute("DROP TABLE IF EXISTS " + table.getTableName());
|
||||
jdbc.execute("CREATE TABLE " + table.getTableName() + " (" + String.join(",", columns) + ") ENGINE=InnoDB");
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立一次测试所需的固定卡片及钱包,不读取或改写任何业务人员数据。
|
||||
* @param cardId 测试开卡记录主键
|
||||
* @param walletId 测试钱包主键,用户编号固定为该值加一百
|
||||
* @param tenant 测试租户编号
|
||||
* @param department 测试部门编号,权限夹具只允许 501
|
||||
* @param balance 初始余额及累计充值,单位分
|
||||
*/
|
||||
private static void seed(long cardId, long walletId, long tenant, long department, int balance) {
|
||||
jdbc.update("INSERT INTO wallet_active (id,user_id,wallet_id,wallet_type_id,dept_id,card_number,is_active_hardware,tenant_id) VALUES (?,?,?,?,?,?,?,?)",
|
||||
cardId, walletId + 100, walletId, 401, department, "card" + cardId, "0", tenant);
|
||||
jdbc.update("INSERT INTO wallet_manage (id,user_id,wallet_type_id,dept_id,card_number,balance,total_expense,total_recharge,is_freeze,status,tenant_id) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
walletId, walletId + 100, 401, department, "card" + cardId, balance, 0, balance, "0", "0", tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建本次请求与独立预检快照,直接 JDBC 只用于构造测试操作者看到的输入。
|
||||
* @param price 单卡开卡费,单位分
|
||||
* @param ids 所选测试卡片编号
|
||||
* @return 包含完整快照的收费请求
|
||||
*/
|
||||
private static WalletOpenCardFeeDto request(int price, long... ids) {
|
||||
WalletOpenCardFeeDto dto = new WalletOpenCardFeeDto();
|
||||
dto.setPrice(price);
|
||||
List<Long> selected = new ArrayList<>();
|
||||
List<WalletOpenCardFeeDto.Snapshot> snapshots = new ArrayList<>();
|
||||
for (long id : ids) {
|
||||
selected.add(id);
|
||||
var row = jdbc.queryForMap("SELECT user_id,wallet_id,card_number FROM wallet_active WHERE id=?", id);
|
||||
WalletOpenCardFeeDto.Snapshot snapshot = new WalletOpenCardFeeDto.Snapshot();
|
||||
snapshot.setId(id);
|
||||
snapshot.setUserId(((Number) row.get("user_id")).longValue());
|
||||
snapshot.setWalletId(((Number) row.get("wallet_id")).longValue());
|
||||
snapshot.setCardNumber((String) row.get("card_number"));
|
||||
snapshots.add(snapshot);
|
||||
}
|
||||
dto.setIds(selected);
|
||||
dto.setSnapshots(snapshots);
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为真实服务附加 Spring 注解事务,验证业务异常触发数据库回滚。
|
||||
* @param target 被测服务实现
|
||||
* @param type 服务接口
|
||||
* @param transactions 绑定本轮隔离数据源的事务管理器
|
||||
* @param <T> 被测服务契约
|
||||
* @return 使用真实事务拦截器的服务代理
|
||||
*/
|
||||
private static <T> T transactional(Object target, Class<T> type, DataSourceTransactionManager transactions) {
|
||||
ProxyFactory factory = new ProxyFactory(target);
|
||||
factory.setInterfaces(type);
|
||||
factory.addAdvice(new TransactionInterceptor(transactions, new AnnotationTransactionAttributeSource()));
|
||||
return type.cast(factory.getProxy());
|
||||
}
|
||||
|
||||
/** @param sql 仅查询本轮隔离夹具的单值 SQL @return 查询值的整数表示 */
|
||||
private static int scalar(String sql) {
|
||||
return jdbc.queryForObject(sql, Integer.class);
|
||||
}
|
||||
}
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
package com.bsd.pjcloud.wallet.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.SharedString;
|
||||
import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
|
||||
import com.bsd.pjcloud.wallet.api.constant.WalletTransactionTag;
|
||||
import com.bsd.pjcloud.wallet.api.dto.WalletActiveDto;
|
||||
import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto;
|
||||
import com.bsd.pjcloud.wallet.api.dto.WalletOpenCardFeeDto;
|
||||
import com.bsd.pjcloud.wallet.api.entity.WalletActiveEntity;
|
||||
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
|
||||
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
|
||||
import com.bsd.pjcloud.wallet.api.vo.WalletOpenCardFeeVo;
|
||||
import com.bsd.pjcloud.wallet.mapper.WalletActiveMapper;
|
||||
import com.bsd.pjcloud.wallet.mapper.WalletManageMapper;
|
||||
import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper;
|
||||
import com.bsd.pjcloud.wallet.service.WalletOpenCardFeeService;
|
||||
import com.bsd.pjcloud.wallet.service.WalletTransactionService;
|
||||
import com.bsd.pjcloud.wallet.support.WalletMessageContextHolder;
|
||||
import com.bsd.pjcloud.wallet.support.WalletUserIdentitySupport;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 开卡费的整批拦截、绑定变化、历史幂等、流水文案及事务失败回滚回归测试。 */
|
||||
class WalletOpenCardFeeServiceImplTest {
|
||||
|
||||
/** 模拟当前数据权限内的开卡记录及锁定后最新记录。 */
|
||||
@Mock
|
||||
private WalletActiveMapper cardMapper;
|
||||
|
||||
/** 模拟钱包余额、绑定与锁定读结果。 */
|
||||
@Mock
|
||||
private WalletManageMapper walletMapper;
|
||||
|
||||
/** 模拟历史开卡费流水,检查时不受前端金额影响。 */
|
||||
@Mock
|
||||
private WalletTransactionMapper transactionMapper;
|
||||
|
||||
/** 捕获实际交给钱包基础服务的扣款明细。 */
|
||||
@Mock
|
||||
private WalletTransactionService transactionService;
|
||||
|
||||
/** 模拟存在且在人员数据权限内的绑定用户。 */
|
||||
@Mock
|
||||
private WalletUserIdentitySupport identitySupport;
|
||||
|
||||
/** 使用真实业务代码,数据库与远程接口由上面的 Mock 隔离。 */
|
||||
@InjectMocks
|
||||
private WalletOpenCardFeeServiceImpl service;
|
||||
|
||||
/** 使用当前项目 Mockito 2 的初始化入口,避免与另一个版本的扩展组件混用。 */
|
||||
@BeforeEach
|
||||
void initializeMocks() {
|
||||
MockitoAnnotations.initMocks(this);
|
||||
}
|
||||
|
||||
/** 未硬件激活及钱包未激活仍允许首次打印,预检按元转分后的单卡金额汇总。 */
|
||||
@Test
|
||||
void previewAllowsUnactivatedCardWithExactBalance() {
|
||||
stub(card(), wallet(1550), false);
|
||||
|
||||
WalletOpenCardFeeVo result = service.preview(request(1550));
|
||||
|
||||
assertEquals(1, result.getPendingCount());
|
||||
assertEquals(1550L, result.getTotalPrice());
|
||||
assertEquals(1550, result.getItems().get(0).getChargePrice());
|
||||
verifyZeroInteractions(transactionService);
|
||||
}
|
||||
|
||||
/** 整批中存在未绑定卡片时,即使其余卡片正常也不能产生任何扣款。 */
|
||||
@Test
|
||||
void unboundCardStopsWholeBatch() {
|
||||
WalletActiveEntity unbound = WalletActiveEntity.builder().id(11L).cardNumber("zax11").build();
|
||||
stub(card(), wallet(5000), true);
|
||||
when(cardMapper.selectListByScope(any(), any())).thenReturn(List.of(card(), unbound));
|
||||
when(cardMapper.selectOne(any(Wrapper.class))).thenReturn(card(), unbound);
|
||||
WalletOpenCardFeeDto dto = request(1500);
|
||||
dto.setIds(List.of(10L, 11L));
|
||||
dto.setSnapshots(List.of(snapshot(card()), snapshot(unbound)));
|
||||
|
||||
WalletOpenCardFeeVo result = service.charge(dto);
|
||||
|
||||
assertEquals(1, result.getAbnormalCount());
|
||||
assertEquals("未绑定用户", result.getItems().get(1).getReason());
|
||||
verifyZeroInteractions(transactionService);
|
||||
}
|
||||
|
||||
/** 余额不足只返回异常,不允许依赖前端检查后直接执行扣款。 */
|
||||
@Test
|
||||
void insufficientBalanceStopsCharge() {
|
||||
stub(card(), wallet(1499), true);
|
||||
|
||||
WalletOpenCardFeeVo result = service.charge(request(1500));
|
||||
|
||||
assertEquals(1, result.getAbnormalCount());
|
||||
assertTrue(result.getItems().get(0).getReason().contains("余额不足"));
|
||||
verifyZeroInteractions(transactionService);
|
||||
}
|
||||
|
||||
/** 同一钱包关联多笔开卡明细时按合计校验,防止逐行均通过而只能扣到部分卡片。 */
|
||||
@Test
|
||||
void checksCombinedFeeForSharedWallet() {
|
||||
WalletActiveEntity second = card();
|
||||
second.setId(11L);
|
||||
stub(card(), wallet(2000), true);
|
||||
when(cardMapper.selectListByScope(any(), any())).thenReturn(List.of(card(), second));
|
||||
when(cardMapper.selectOne(any(Wrapper.class))).thenReturn(card(), second);
|
||||
WalletOpenCardFeeDto dto = request(1500);
|
||||
dto.setIds(List.of(10L, 11L));
|
||||
dto.setSnapshots(List.of(snapshot(card()), snapshot(second)));
|
||||
|
||||
assertEquals(2, service.charge(dto).getAbnormalCount());
|
||||
|
||||
verifyZeroInteractions(transactionService);
|
||||
}
|
||||
|
||||
/** 预检后用户、钱包或卡号变化时必须拒绝本次收费,不能向新的绑定用户扣款。 */
|
||||
@Test
|
||||
void rejectsChangedBindingSnapshot() {
|
||||
stub(card(), wallet(5000), true);
|
||||
WalletOpenCardFeeDto dto = request(1500);
|
||||
dto.getSnapshots().get(0).setWalletId(999L);
|
||||
|
||||
WalletOpenCardFeeVo result = service.charge(dto);
|
||||
|
||||
assertTrue(result.getItems().get(0).getReason().contains("已变化"));
|
||||
verifyZeroInteractions(transactionService);
|
||||
}
|
||||
|
||||
/** 历史费用仍按卡片主键识别,换绑钱包、修改本次金额和余额不足均不会重复收费。 */
|
||||
@Test
|
||||
void historicalFeeSurvivesAmountAndWalletChanges() {
|
||||
stub(card(), wallet(0), true);
|
||||
WalletTransactionEntity history = transaction(1500);
|
||||
history.setWalletId(999L);
|
||||
when(transactionMapper.selectList(any(Wrapper.class))).thenReturn(List.of(history));
|
||||
|
||||
WalletOpenCardFeeVo result = service.charge(request(9900));
|
||||
|
||||
assertEquals(1, result.getChargedCount());
|
||||
assertEquals(0, result.getTotalPrice());
|
||||
assertEquals(1500, result.getItems().get(0).getChargedPrice());
|
||||
assertEquals(0, result.getItems().get(0).getChargePrice());
|
||||
verifyZeroInteractions(transactionService);
|
||||
}
|
||||
|
||||
/** 小数金额使用消费类型和开卡标签,两项备注在交给流水保存前已完全一致。 */
|
||||
@Test
|
||||
void chargePassesExactAmountTagAndBothRemarks() {
|
||||
stub(card(), wallet(5000), true);
|
||||
when(walletMapper.selectByIdForUpdate(20L)).thenReturn(wallet(5000), wallet(3450));
|
||||
when(transactionService.reduceWalletBalanceBatchIdempotent(anyList())).thenAnswer(invocation -> {
|
||||
List<WalletBalanceTransactionDto> payments = invocation.getArgument(0);
|
||||
WalletBalanceTransactionDto payment = payments.get(0);
|
||||
assertEquals(1, payments.size());
|
||||
assertEquals(1550, payment.getPrice());
|
||||
assertEquals(WalletBizEnum.PAYMENT, payment.getBizType());
|
||||
assertEquals("OPEN_CARD:10", payment.getBizId());
|
||||
assertEquals(WalletTransactionTag.OPEN_CARD.getCode(), payment.getTags());
|
||||
assertEquals("开卡费用15.5元", payment.getRemark());
|
||||
assertEquals(payment.getRemark(), payment.getSysRemark());
|
||||
assertFalse(payment.getForce());
|
||||
assertSame(payment, WalletMessageContextHolder.find(20L, "OPEN_CARD:10", WalletBizEnum.PAYMENT.getCode()));
|
||||
return List.of(transaction(1550));
|
||||
});
|
||||
|
||||
WalletOpenCardFeeVo result = service.charge(request(1550));
|
||||
|
||||
assertEquals(1, result.getChargedCount());
|
||||
assertEquals(3450, result.getItems().get(0).getBalance());
|
||||
assertEquals(1550, result.getItems().get(0).getChargedPrice());
|
||||
assertEquals(0, result.getItems().get(0).getChargePrice());
|
||||
assertNull(WalletMessageContextHolder.find(20L, "OPEN_CARD:10", WalletBizEnum.PAYMENT.getCode()));
|
||||
}
|
||||
|
||||
/** 当前读必须在卡片锁之后执行,等待并发收费完成的重试应识别刚提交的历史流水。 */
|
||||
@Test
|
||||
void retryReadsCommittedFeeAfterLockingCard() {
|
||||
stub(card(), wallet(0), true);
|
||||
when(transactionMapper.selectList(any(Wrapper.class))).thenAnswer(invocation -> {
|
||||
verify(cardMapper).selectOne(any(Wrapper.class));
|
||||
Wrapper<?> query = invocation.getArgument(0);
|
||||
SharedString lastSql = (SharedString) ReflectionTestUtils.getField(query, "lastSql");
|
||||
assertTrue(lastSql.getStringValue().contains("FOR UPDATE"));
|
||||
return List.of(transaction(1500));
|
||||
});
|
||||
|
||||
assertEquals(1, service.charge(request(1500)).getChargedCount());
|
||||
|
||||
verifyZeroInteractions(transactionService);
|
||||
}
|
||||
|
||||
/** 挂失、冻结、锁卡及销卡状态均拦截打印前扣费。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = { "2", "3", "4", "5" })
|
||||
void rejectsUnavailableWalletStatuses(String status) {
|
||||
WalletManageEntity wallet = wallet(5000);
|
||||
wallet.setStatus(status);
|
||||
stub(card(), wallet, true);
|
||||
|
||||
assertEquals(1, service.charge(request(1500)).getAbnormalCount());
|
||||
|
||||
verifyZeroInteractions(transactionService);
|
||||
}
|
||||
|
||||
/** 钱包独立冻结标志也必须生效,避免仅检查卡片状态而允许冻结账户收费。 */
|
||||
@Test
|
||||
void rejectsFrozenWalletFlag() {
|
||||
WalletManageEntity wallet = wallet(5000);
|
||||
wallet.setIsFreeze("1");
|
||||
stub(card(), wallet, true);
|
||||
|
||||
assertEquals("钱包已冻结", service.charge(request(1500)).getItems().get(0).getReason());
|
||||
|
||||
verifyZeroInteractions(transactionService);
|
||||
}
|
||||
|
||||
/** 历史关联的钱包被删除时必须逐卡提示,禁止自动新建钱包后继续扣费。 */
|
||||
@Test
|
||||
void missingWalletStopsCharge() {
|
||||
stub(card(), null, true);
|
||||
|
||||
assertEquals("绑定钱包不存在", service.charge(request(1500)).getItems().get(0).getReason());
|
||||
|
||||
verifyZeroInteractions(transactionService);
|
||||
}
|
||||
|
||||
/** 钱包所有人与开卡用户不一致时拦截,防止历史异常关联向其他用户收费。 */
|
||||
@Test
|
||||
void walletOwnerMismatchStopsCharge() {
|
||||
WalletManageEntity wallet = wallet(5000);
|
||||
wallet.setUserId(999L);
|
||||
stub(card(), wallet, true);
|
||||
|
||||
assertEquals("饭卡与钱包绑定信息不一致", service.charge(request(1500)).getItems().get(0).getReason());
|
||||
|
||||
verifyZeroInteractions(transactionService);
|
||||
}
|
||||
|
||||
/** 绑定的用户已不存在或超出人员权限时拦截,不能只凭开卡表中的用户编号收费。 */
|
||||
@Test
|
||||
void missingUserStopsCharge() {
|
||||
stub(card(), wallet(5000), true);
|
||||
when(identitySupport.findIdentityUserIds(any(WalletActiveDto.class), isNull(), anyList())).thenReturn(List.of(-1L));
|
||||
|
||||
assertEquals("绑定用户不存在或无权访问", service.charge(request(1500)).getItems().get(0).getReason());
|
||||
|
||||
verifyZeroInteractions(transactionService);
|
||||
}
|
||||
|
||||
/** 数据权限未返回的记录不能继续读取用户钱包或历史流水,也不能泄露卡号。 */
|
||||
@Test
|
||||
void unauthorizedRecordDoesNotReadWalletOrCharge() {
|
||||
when(cardMapper.selectListByScope(any(), any())).thenReturn(List.of());
|
||||
|
||||
WalletOpenCardFeeVo result = service.charge(request(1500));
|
||||
|
||||
assertEquals(1, result.getAbnormalCount());
|
||||
assertNull(result.getItems().get(0).getCardNumber());
|
||||
verifyZeroInteractions(walletMapper, transactionMapper, transactionService);
|
||||
}
|
||||
|
||||
/** 未携带预检快照的调用在查询钱包前失败,不能绕过操作者看到的绑定确认。 */
|
||||
@Test
|
||||
void missingSnapshotIsRejectedBeforeReadingData() {
|
||||
WalletOpenCardFeeDto dto = request(1500);
|
||||
dto.setSnapshots(null);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> service.charge(dto));
|
||||
|
||||
verifyZeroInteractions(cardMapper, walletMapper, transactionMapper, transactionService);
|
||||
}
|
||||
|
||||
/** 零元、负数金额在服务入口失败,避免被直接调用绕过 Controller 的校验。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = { 0, -1 })
|
||||
void rejectsNonPositivePrice(int price) {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.preview(request(price)));
|
||||
verifyZeroInteractions(cardMapper, walletMapper, transactionMapper, transactionService);
|
||||
}
|
||||
|
||||
/** 下游扣款或 Outbox 保存异常必须由真实 Spring 事务拦截器回滚,禁止吞异常后提交。 */
|
||||
@Test
|
||||
void batchFailureRollsBackOuterTransaction() {
|
||||
stub(card(), wallet(5000), true);
|
||||
when(transactionService.reduceWalletBalanceBatchIdempotent(anyList())).thenThrow(new IllegalStateException("Outbox保存失败"));
|
||||
PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class);
|
||||
TransactionStatus status = mock(TransactionStatus.class);
|
||||
when(transactionManager.getTransaction(any())).thenReturn(status);
|
||||
ProxyFactory factory = new ProxyFactory(service);
|
||||
factory.addAdvice(new TransactionInterceptor(transactionManager, new AnnotationTransactionAttributeSource()));
|
||||
WalletOpenCardFeeService transactionalService = (WalletOpenCardFeeService) factory.getProxy();
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> transactionalService.charge(request(1500)));
|
||||
|
||||
verify(transactionManager).rollback(status);
|
||||
verify(transactionManager, never()).commit(any());
|
||||
assertNull(WalletMessageContextHolder.find(20L, "OPEN_CARD:10", WalletBizEnum.PAYMENT.getCode()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置单张可见卡片及钱包,用户资料查询继续由现有权限组件模拟。
|
||||
* @param card 当前数据权限内的开卡记录
|
||||
* @param wallet 实际钱包快照
|
||||
* @param locking 是否走扣款时的锁定读取
|
||||
*/
|
||||
private void stub(WalletActiveEntity card, WalletManageEntity wallet, boolean locking) {
|
||||
when(cardMapper.selectListByScope(any(), any())).thenReturn(List.of(card));
|
||||
if (locking) {
|
||||
when(cardMapper.selectOne(any(Wrapper.class))).thenReturn(card);
|
||||
when(walletMapper.selectByIdForUpdate(20L)).thenReturn(wallet);
|
||||
} else {
|
||||
when(walletMapper.selectById(20L)).thenReturn(wallet);
|
||||
}
|
||||
when(transactionMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
|
||||
when(identitySupport.findIdentityUserIds(any(WalletActiveDto.class), isNull(), anyList())).thenReturn(List.of(30L));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 尚未硬件激活但已正确绑定用户和钱包的卡片
|
||||
*/
|
||||
private WalletActiveEntity card() {
|
||||
return WalletActiveEntity.builder().id(10L).userId(30L).walletId(20L).walletTypeId(40L)
|
||||
.cardNumber("zax10").isActiveHardware("0").build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param balance 钱包余额,单位分
|
||||
* @return 与测试卡片对应的钱包
|
||||
*/
|
||||
private WalletManageEntity wallet(int balance) {
|
||||
return WalletManageEntity.builder().id(20L).userId(30L).walletTypeId(40L).cardNumber("zax10")
|
||||
.balance(balance).totalExpense(0).totalRecharge(balance).status("0").isFreeze("0").build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param price 本次单卡金额,单位分
|
||||
* @return 带有完整预检绑定快照的扣款请求
|
||||
*/
|
||||
private WalletOpenCardFeeDto request(int price) {
|
||||
WalletOpenCardFeeDto dto = new WalletOpenCardFeeDto();
|
||||
dto.setIds(List.of(10L));
|
||||
dto.setPrice(price);
|
||||
dto.setSnapshots(List.of(snapshot(card())));
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param card 预检看到的卡片
|
||||
* @return 仅比较用户、钱包及卡号的独立快照
|
||||
*/
|
||||
private WalletOpenCardFeeDto.Snapshot snapshot(WalletActiveEntity card) {
|
||||
WalletOpenCardFeeDto.Snapshot snapshot = new WalletOpenCardFeeDto.Snapshot();
|
||||
snapshot.setId(card.getId());
|
||||
snapshot.setUserId(card.getUserId());
|
||||
snapshot.setWalletId(card.getWalletId());
|
||||
snapshot.setCardNumber(card.getCardNumber());
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param price 历史实际收取的金额,单位分
|
||||
* @return 对应该卡的成功消费流水
|
||||
*/
|
||||
private WalletTransactionEntity transaction(int price) {
|
||||
WalletTransactionEntity transaction = new WalletTransactionEntity();
|
||||
transaction.setId(100L);
|
||||
transaction.setWalletId(20L);
|
||||
transaction.setBizId("OPEN_CARD:10");
|
||||
transaction.setBizType(WalletBizEnum.PAYMENT.getCode());
|
||||
transaction.setPrice(-price);
|
||||
transaction.setBalance(5000 - price);
|
||||
return transaction;
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -20,6 +20,7 @@ import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper;
|
||||
import com.bsd.pjcloud.wallet.mapper.WalletTypeMapper;
|
||||
import com.bsd.pjcloud.wallet.service.MchManageService;
|
||||
import com.bsd.pjcloud.wallet.service.WalletManageService;
|
||||
import com.bsd.pjcloud.wallet.support.WalletUserIdentitySupport;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -74,6 +75,10 @@ class WalletTransactionServiceImplTest {
|
||||
@Mock
|
||||
private MchManageService mchManageService;
|
||||
|
||||
/** 当前流水查询新增的统一用户身份组件,空筛选时保持原查询范围。 */
|
||||
@Mock
|
||||
private WalletUserIdentitySupport userIdentitySupport;
|
||||
|
||||
/** 被测钱包流水服务,其余与当前授权场景无关的依赖由 Mockito 注入空值。 */
|
||||
@InjectMocks
|
||||
private WalletTransactionServiceImpl service;
|
||||
@@ -197,7 +202,7 @@ class WalletTransactionServiceImplTest {
|
||||
when(walletManageMapper.selectById(20L)).thenReturn(wallet);
|
||||
when(walletMessageOutboxMapper.insert(any(WalletMessageOutboxEntity.class))).thenReturn(1);
|
||||
|
||||
WalletTransactionEntity result = service.addWalletBalanceIdempotent(20L, 50, "pay-1001", WalletBizEnum.RECHARGE);
|
||||
WalletTransactionEntity result = service.addWalletBalanceIdempotent(20L, 50, "pay-1001", WalletBizEnum.RECHARGE, null, null, null);
|
||||
|
||||
assertEquals(50, result.getPrice());
|
||||
assertEquals(150, result.getBalance());
|
||||
@@ -222,7 +227,7 @@ class WalletTransactionServiceImplTest {
|
||||
when(walletManageMapper.selectByIdForUpdate(20L)).thenReturn(wallet);
|
||||
when(walletTransactionMapper.selectOne(any(Wrapper.class))).thenReturn(existing);
|
||||
|
||||
assertSame(existing, service.addWalletBalanceIdempotent(20L, 50, "pay-1001", WalletBizEnum.RECHARGE));
|
||||
assertSame(existing, service.addWalletBalanceIdempotent(20L, 50, "pay-1001", WalletBizEnum.RECHARGE, null, null, null));
|
||||
|
||||
verify(walletManageMapper, never()).updateWhenRecharge(any(), any());
|
||||
verify(walletTransactionMapper, never()).insert(any(WalletTransactionEntity.class));
|
||||
|
||||
Reference in new issue
Block a user