diff --git a/db/20261110_migrate_business_jobs_to_sys_job.sql b/db/20261110_migrate_business_jobs_to_sys_job.sql index a1a37bc20..f0e48233b 100644 --- a/db/20261110_migrate_business_jobs_to_sys_job.sql +++ b/db/20261110_migrate_business_jobs_to_sys_job.sql @@ -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; diff --git a/db/20261113_mch_meal_time_config.sql b/db/20261113_mch_meal_time_config.sql new file mode 100644 index 000000000..c5d5af461 --- /dev/null +++ b/db/20261113_mch_meal_time_config.sql @@ -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; diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOrderingFoodServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOrderingFoodServiceImpl.java index 1faf96d13..32c59af25 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOrderingFoodServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOrderingFoodServiceImpl.java @@ -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 segments = MchJobTimeMealSegmentUtils.parseMealSegments(mch.getJobTime()); + List 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 segments = mch != null - ? MchJobTimeMealSegmentUtils.parseMealSegments(mch.getJobTime()) + ? MchJobTimeMealSegmentUtils.parseMealSegments(MchMealTimeUtils.toReportingIntervals(mch)) : List.of(); var q = Wrappers.lambdaQuery() .eq(CanteenOrderingFoodEntity::getMerchantId, merchantId) @@ -572,7 +573,7 @@ public class CanteenOrderingFoodServiceImpl extends ServiceImpl segmentDefs = mch != null - ? MchJobTimeMealSegmentUtils.parseMealSegments(mch.getJobTime()) + ? MchJobTimeMealSegmentUtils.parseMealSegments(MchMealTimeUtils.toReportingIntervals(mch)) : List.of(); if (CollUtil.isEmpty(segmentDefs)) { OrderingFoodStatsDayDetailVO empty = new OrderingFoodStatsDayDetailVO(); diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardHourTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardHourTypeEnum.java index eb206c679..6c3a2c49e 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardHourTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardHourTypeEnum.java @@ -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; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptMerchantModeService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptMerchantModeService.java index 47e590882..ed861d9fe 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptMerchantModeService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptMerchantModeService.java @@ -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 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 requested) { + if (!hourTypes().containsAll(requested)) { + throw new RuntimeException("预约规则包含商户未启用的订餐时间段,请重新选择"); + } + } } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptCycleRuleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptCycleRuleServiceImpl.java index 5d50e1e6c..3f5b1f09d 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptCycleRuleServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptCycleRuleServiceImpl.java @@ -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())) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java index 079362c82..1a74c2385 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java @@ -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 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> 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 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 filterMerchantOptions(List options, HardApptMerchantModeService.MerchantModeContext context) { + if (!context.reservationEnabled()) return List.of(); + List 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() { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java index 95bfd001d..5855b0808 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java @@ -78,6 +78,7 @@ public class HardApptServiceImpl extends ServiceImpl 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 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() .eq(HardApptEntity::getId, dto.getId()).eq(HardApptEntity::getMchId, dto.getMchId()), dataScope).isEmpty()) { throw new RuntimeException("预约规则不存在或不在当前数据权限范围内"); } List draft = validateAndNormalizeWindows(dto); + validateMerchantMeals(draft, merchantContext); List sources = new ArrayList<>(listEnabledWindows(dto.getMchId()).stream() .filter(source -> !Objects.equals(source.window().getApptId(), dto.getId())).toList()); Map> dates = new java.util.TreeMap<>(); @@ -362,7 +366,10 @@ public class HardApptServiceImpl extends ServiceImpl { 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 windows, HardApptMerchantModeService.MerchantModeContext context) { + context.validateHourTypes(windows.stream().flatMap(window -> window.getItemList().stream()) + .map(HardApptItemDto::getHourType).toList()); + } + /** * 餐段规则只在商户当前选择动态模式时允许维护,切换模式不会删除原有数据。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardTakeOutDeliveryTimeOptionServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardTakeOutDeliveryTimeOptionServiceImpl.java index 9f86e579c..753ee01e5 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardTakeOutDeliveryTimeOptionServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardTakeOutDeliveryTimeOptionServiceImpl.java @@ -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); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index a6daf3830..a2792d7eb 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -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 merged = mergeJobTimeIntervals(jobTime); List 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; diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java index ca96fe079..4d03ee34f 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java @@ -199,6 +199,16 @@ public interface RemoteUserService { @PostMapping("/user/user-dept") R> getUserDeptByIds(@RequestBody List userIds); + /** + * 在原用户批量查询范围内按需返回身份扩展资料,供关系用户列表展示学生字段。 + * + * @param userIds 已由业务确定的目标用户 ID 列表 + * @param includeIdentityInfo 是否补齐学生、老师资料;旧调用保持只查询身份编码 + * @return 用户与身份资料;调用方必须检查业务状态和空数据 + */ + @PostMapping("/user/user-dept") + R> getUserDeptByIds(@RequestBody List userIds, @RequestParam("includeIdentityInfo") boolean includeIdentityInfo); + /** * 通过社交账号或手机号查询用户、角色信息 * diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserGuardianWardOptionVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserGuardianWardOptionVo.java index 1542b90dc..916f6becb 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserGuardianWardOptionVo.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserGuardianWardOptionVo.java @@ -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 identityTypes; + + /** 学生的学校、学号、班级和监护人手机号;非学生或尚未维护时为空。 */ + @Schema(description = "被监护人的学生身份资料") + @JsonIgnoreProperties("extJson") + private SysUserStudentDto studentInfo; + + /** 老师学号资料;无老师身份或尚未维护时为空。 */ + @Schema(description = "被监护人的老师身份资料") + private SysUserTeacherDto teacherInfo; } diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserRelationGraphVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserRelationGraphVo.java index c536d15e0..28667fa6b 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserRelationGraphVo.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserRelationGraphVo.java @@ -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 identityTypes; + + /** 学生身份资料,非学生或未维护时为空;图谱不返回非核心扩展 JSON。 */ + @Schema(description = "学生的学校、学号、班级和监护人手机号") + @JsonIgnoreProperties("extJson") + private SysUserStudentDto studentInfo; + @Schema(description = "图谱中的用户数量,仅根节点返回,重复用户不重复计数") private Integer userCount; diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index 1fbbb6148..62239416e 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -828,14 +828,15 @@ public class SysUserController { } /** - * 按用户 ID 批量查询用户、部门及身份协议编码。 + * 按用户 ID 批量查询用户、部门及身份协议编码,可按需返回身份扩展资料。 * * @param userIds 待查询用户 ID 列表 + * @param includeIdentityInfo 是否补齐学生、老师资料;省略时保持旧接口行为 * @return 用户、部门和身份资料;不存在的用户不返回记录 */ @PostMapping("/user-dept") - public R> getUserDeptByIds(@RequestBody List userIds) { - return R.ok(userService.getUserDeptByIds(userIds)); + public R> getUserDeptByIds(@RequestBody List userIds, @RequestParam(value = "includeIdentityInfo", defaultValue = "false") boolean includeIdentityInfo) { + return R.ok(userService.getUserDeptByIds(userIds, includeIdentityInfo)); } /** diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java index 4783f1736..1d11becdc 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java @@ -256,6 +256,15 @@ public interface SysUserService extends IService { */ List getUserDeptByIds(List userIds); + /** + * 按既有批量查询范围读取用户,可选补齐身份扩展资料,不应用操作者数据权限筛选。 + * + * @param userIds 由调用业务确定的用户 ID,租户隔离由既有数据组件处理 + * @param includeIdentityInfo 是否批量装配学生、老师资料;为 false 时只返回身份编码 + * @return 对应的用户资料;空 ID 列表返回空列表,不存在的用户不返回记录 + */ + List getUserDeptByIds(List userIds, boolean includeIdentityInfo); + /** * 查询所有用户简单信息 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index 4b9871a26..bb8853b91 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -1329,9 +1329,28 @@ public class SysUserServiceImpl extends ServiceImpl impl */ @Override public List getUserDeptByIds(List userIds) { + return getUserDeptByIds(userIds, false); + } + + /** + * 按需批量装配身份资料,保持原查询范围,避免普通身份校验额外读取学生和老师扩展表。 + * + * @param userIds 业务已确定的用户 ID 列表;为空时不查询 + * @param includeIdentityInfo 是否返回身份扩展资料 + * @return 已补齐身份编码及所请求扩展资料的用户列表 + */ + @Override + public List getUserDeptByIds(List userIds, boolean includeIdentityInfo) { + if (CollUtil.isEmpty(userIds)) { + return List.of(); + } List 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; } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java index 851f69f97..9044b637a 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java @@ -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 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 wardUsers = sysUserService.getUserDeptByIds(wardUserIds); + List wardUsers = sysUserService.getUserDeptByIds(wardUserIds, true); if (CollUtil.isEmpty(wardUsers)) { return List.of(); } @@ -409,9 +414,9 @@ public class SysUserRelationServiceImpl extends ServiceImpl nodes) { + Map 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 userMap, Map relationTypeMap) { SysUser user = userMap.get(node.userId); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImplTest.java b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImplTest.java index 62a710055..dcfdd8777 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImplTest.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImplTest.java @@ -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.>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(); diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletOpenCardFeeConstants.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletOpenCardFeeConstants.java new file mode 100644 index 000000000..2147e1487 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletOpenCardFeeConstants.java @@ -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() { + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletSysRemarkTemplate.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletSysRemarkTemplate.java index 7d91500cc..505b68510 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletSysRemarkTemplate.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletSysRemarkTemplate.java @@ -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 = "钱包充值{}元"; diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTransactionTag.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTransactionTag.java index 4ad363069..c5071720d 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTransactionTag.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTransactionTag.java @@ -15,6 +15,7 @@ public enum WalletTransactionTag { BJZ_CZ("1", "不记账充值"), CS_CZ("2", "测试充值"), + OPEN_CARD("3", "开卡金额"), ; private final String code; diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletOpenCardFeeDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletOpenCardFeeDto.java new file mode 100644 index 000000000..c3efd1fea --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletOpenCardFeeDto.java @@ -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 snapshots; + + /** 操作者预检时看到的卡片归属,仅用于比较变化,不作为扣款目标的可信来源。 */ + @Data + public static class Snapshot { + + /** 对应的 wallet_active 主键,每个扣款批次内必须唯一。 */ + @NotNull(message = "开卡记录编号不能为空") + private Long id; + + /** 预检时绑定用户的主键,未绑定时为空并由业务预检阻止扣款。 */ + private Long userId; + + /** 预检时绑定钱包的主键,服务端扣款时重新读取并比较。 */ + private Long walletId; + + /** 预检时卡号,卡号被编辑后必须重新预检才能继续扣款和打印。 */ + private String cardNumber; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchManageEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchManageEntity.java index fbf118571..fa9cbfe27 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchManageEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchManageEntity.java @@ -111,12 +111,15 @@ public class MchManageEntity extends Model { @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 { @Data public static class TableReservationConf { + /** + * 商户启用的订餐餐段编码,来自 daily_menu 字典并按字典顺序保存。 + * 历史 JSON 缺少该属性时为 null,表示沿用原餐段范围;明确保存时至少选择一个餐段。 + */ + private List hourTypes; + /** * 订餐模式 * diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchMealTimeConfig.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchMealTimeConfig.java new file mode 100644 index 000000000..91bbfb7da --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchMealTimeConfig.java @@ -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> 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; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/utils/MchMealTimeUtils.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/utils/MchMealTimeUtils.java new file mode 100644 index 000000000..6b6072786 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/utils/MchMealTimeUtils.java @@ -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> 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 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 enabledPeriods(MchManageEntity merchant) { + if (merchant == null) return List.of(); + List 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 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 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 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 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(); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletOpenCardFeeVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletOpenCardFeeVo.java new file mode 100644 index 000000000..9cdaeacae --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletOpenCardFeeVo.java @@ -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 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; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/app/GuardianWardRechargeItemVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/app/GuardianWardRechargeItemVo.java index d089286cb..23bd704b4 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/app/GuardianWardRechargeItemVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/app/GuardianWardRechargeItemVo.java @@ -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 identityTypes; + /** 学生的学校、学号、班级和监护人手机号;非学生或未维护时为空,不返回非核心扩展 JSON。 */ + @JsonIgnoreProperties("extJson") + private SysUserStudentDto studentInfo; + /** * 审核通过且当前可用的人脸数量。 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java index 92d18c515..ae13a680f 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java @@ -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 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 chargeOpenCardFee(@Valid @RequestBody WalletOpenCardFeeDto dto) { + return R.ok(walletOpenCardFeeService.charge(dto)); + } + @PostMapping("/fast-create-card") @Operation(summary = "一键开卡") @Idempotent(info = "正在开卡中,请勿重复提交") diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletOpenCardFeeService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletOpenCardFeeService.java new file mode 100644 index 000000000..e39e4ea16 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletOpenCardFeeService.java @@ -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); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletOpenCardFeeServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletOpenCardFeeServiceImpl.java new file mode 100644 index 000000000..ed2defb71 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletOpenCardFeeServiceImpl.java @@ -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 ids = validateRequest(dto); + Map 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 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 transactions = WalletMessageContextHolder.supplyWith(payments, + () -> walletTransactionService.reduceWalletBalanceBatchIdempotent(payments)); + if (transactions == null || transactions.size() != payments.size()) { + throw new IllegalStateException("开卡费扣款结果不完整,请查询收费状态后重试"); + } + Map charged = new HashMap<>(); + for (WalletTransactionEntity transaction : transactions) { + if (transaction == null || transaction.getId() == null) { + throw new IllegalStateException("开卡费流水保存失败"); + } + charged.put(transaction.getBizId(), transaction); + } + Map 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 ids = validateRequest(dto); + List scoped = walletActiveMapper.selectListByScope( + new LambdaQueryWrapper().in(WalletActiveEntity::getId, ids), DataScope.of()); + Map 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() + .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 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 wallets = loadWallets(items, locking); + Map charged = loadCharged(cards.keySet(), locking); + List userIds = items.stream().map(Item::getUserId).filter(Objects::nonNull).distinct().toList(); + Set 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 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 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 validateSnapshots(WalletOpenCardFeeDto dto, List ids) { + Map 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 loadWallets(List items, boolean locking) { + Map wallets = new LinkedHashMap<>(); + List 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 loadCharged(Set cardIds, boolean locking) { + Map charged = new HashMap<>(); + if (cardIds.isEmpty()) { + return charged; + } + LambdaQueryWrapper query = new LambdaQueryWrapper() + .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 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()); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java index 3de721780..507b401bf 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java @@ -1559,7 +1559,9 @@ public class WalletTransactionServiceImpl extends ServiceImpl { - 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) { diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java index cef04a2d0..58c3ef606 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java @@ -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() + .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 0) { if (!ObjectUtil.equals(mchManage.getTypes(), dto.getTypes())) { @@ -209,6 +221,39 @@ public class MchManageServiceImpl extends ServiceImpl 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> result = remoteDictService.getDictByType("daily_menu"); + if (result == null || !result.isOk() || result.getData() == null) { + throw new IllegalArgumentException("用餐时间的餐段字典读取失败,请重试"); + } + List 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> result = remoteDictService.getDictByType("daily_menu"); + if (result == null || !result.isOk() || ObjectUtil.isEmpty(result.getData())) { + throw new RuntimeException("订餐时间段字典读取失败,请重试"); + } + List selected = conf.getHourTypes().stream().map(String::trim).distinct().toList(); + List 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 配置。 * 充值配置为空时保持原样,以兼容历史商户及“空列表表示不限制身份”的约定。 diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java index 3287e9798..a00c5364d 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java @@ -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 listUserBusinessItems(List userIds, Set wardUserIds, Long currentUserId) { R> 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 + @@ -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, diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/api/handler/MchManageJacksonTypeHandlerTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/api/handler/MchManageJacksonTypeHandlerTest.java index 881c46187..774d70031 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/api/handler/MchManageJacksonTypeHandlerTest.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/api/handler/MchManageJacksonTypeHandlerTest.java @@ -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))); + } + } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/api/utils/MchMealTimeUtilsTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/api/utils/MchMealTimeUtilsTest.java new file mode 100644 index 000000000..71a6bf5e6 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/api/utils/MchMealTimeUtilsTest.java @@ -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; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletOpenCardFeeDatabaseTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletOpenCardFeeDatabaseTest.java new file mode 100644 index 000000000..9ce7f5bd9 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletOpenCardFeeDatabaseTest.java @@ -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> 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 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> 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 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> 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 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 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 selected = new ArrayList<>(); + List 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 被测服务契约 + * @return 使用真实事务拦截器的服务代理 + */ + private static T transactional(Object target, Class 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); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletOpenCardFeeServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletOpenCardFeeServiceImplTest.java new file mode 100644 index 000000000..dbf4f2491 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletOpenCardFeeServiceImplTest.java @@ -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 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; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImplTest.java index 827ea6e15..1e3b00582 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImplTest.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImplTest.java @@ -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));