Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-11-13 13:48:50 +08:00
commit d98f9fb4aa
40 files changed
+2270 -97

No files matched your search

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