package com.ruoyi.system.service; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.MessageUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.system.domain.InfoBankCard; import com.ruoyi.system.domain.InfoUser; import com.ruoyi.system.domain.PaymentMethodConfig; import com.ruoyi.system.domain.PosStoreLinePay; import com.ruoyi.system.domain.PosStoreOmg; import com.ruoyi.system.mapper.InfoBankCardMapper; import com.ruoyi.system.mapper.InfoUserMapper; import com.ruoyi.system.mapper.PaymentMethodConfigMapper; import com.ruoyi.system.mapper.PosStoreLinePayMapper; import com.ruoyi.system.mapper.PosStoreOmgMapper; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; /** * 支付方式闸门服务(031 可配置支付方式)——全系统唯一的支付方式可用性判定入口。 * *

三层闸门:某支付方式可用 = 平台开关(方式×维度,缺行=开) * ∩ 收款方选择(info_user 选择列,NULL=全部)∩ 就绪度(门店凭证或启用银行卡)。 * 所有下单入口(用户餐饮下单、商家建单、闪送下单)与 bankInfo 必须统一调用 * {@link #assertUsable(String, GateScope, Long, Long)},禁止在各自入口重复实现判定逻辑, * 保证闸门规则变化只改本类、全部入口行为一致(spec FR-004)。

* *

刻意不加缓存:开关表最多 4×2 行的小表索引查询,直查天然满足"保存后下一次查询即生效" * (SC-001),避免缓存失效复杂度。

*/ @Service public class PaymentMethodGateService { /** 支付方式组代码:到付(payType=1)。 */ public static final String GROUP_COD = "COD"; /** 支付方式组代码:信用卡+Apple Pay 同组(payType=2、5,OMG 渠道同开同关)。 */ public static final String GROUP_CARD_OMG = "CARD_OMG"; /** 支付方式组代码:LINE Pay(payType=3)。 */ public static final String GROUP_LINE_PAY = "LINE_PAY"; /** 支付方式组代码:线下转账(payType=6)。 */ public static final String GROUP_OFFLINE = "OFFLINE_TRANSFER"; /** 支付方式组代码:现金(payType=4);仅商家勾选管控,无平台开关行与就绪度(031 US7/FR-006)。 */ public static final String GROUP_CASH = "CASH"; /** 现金 payType=4:原 assertUsable 路径(商家建单/闪送缺省现金)直接放行;用户单经 assertUsableForOrder 按订单类型管控。 */ public static final String PAY_TYPE_CASH = "4"; /** payType 数值 → 组代码;不在表内的值视为非法(现金"4"仅走专门分支,不进映射)。 */ private static final Map PAY_TYPE_TO_GROUP = Map.of( "1", GROUP_COD, "2", GROUP_CARD_OMG, "3", GROUP_LINE_PAY, "5", GROUP_CARD_OMG, "6", GROUP_OFFLINE); /** 各维度可管控的组集合;RIDER_FLASH 无线上卡支付(闪送资金直达骑手)且不含现金(缺省支付不经勾选)。 */ private static final Map> SCOPE_GROUPS = Map.of( GateScope.MERCHANT, List.of(GROUP_COD, GROUP_CARD_OMG, GROUP_LINE_PAY, GROUP_OFFLINE, GROUP_CASH), GateScope.RIDER_FLASH, List.of(GROUP_COD, GROUP_LINE_PAY, GROUP_OFFLINE)); /** 闸门维度:商家餐饮单(用户向商家付款)与骑手闪送(用户直达骑手)独立开关。 */ public enum GateScope { MERCHANT, RIDER_FLASH } /** * 供结算页渲染的一项可选支付方式。 * CARD_OMG 组按 payType 展开为 2(信用卡)、5(Apple Pay)两项,组内 ready 一致。 */ public static class PayMethodOption { /** 支付方式组代码。 */ private final String methodCode; /** 对外 payType 数值("1"/"2"/"3"/"5"/"6")。 */ private final String payType; /** 就绪度:false 表示当前不可实际使用(如线下转账无启用卡),前端应置灰或不渲染。 */ private final boolean ready; public PayMethodOption(String methodCode, String payType, boolean ready) { this.methodCode = methodCode; this.payType = payType; this.ready = ready; } public String getMethodCode() { return methodCode; } public String getPayType() { return payType; } public boolean isReady() { return ready; } } private final PaymentMethodConfigMapper configMapper; private final InfoUserMapper userMapper; private final InfoBankCardMapper bankCardMapper; private final PosStoreOmgMapper omgMapper; private final PosStoreLinePayMapper linePayMapper; public PaymentMethodGateService(PaymentMethodConfigMapper configMapper, InfoUserMapper userMapper, InfoBankCardMapper bankCardMapper, PosStoreOmgMapper omgMapper, PosStoreLinePayMapper linePayMapper) { this.configMapper = configMapper; this.userMapper = userMapper; this.bankCardMapper = bankCardMapper; this.omgMapper = omgMapper; this.linePayMapper = linePayMapper; } /** * 校验支付方式可用,不可用抛出国际化异常;三下单入口与 bankInfo 统一调用(唯一入口)。 * * @param payType 支付方式数值(与订单 payType 同域) * @param scope 闸门维度 * @param storeId 门店 ID(MERCHANT 维度线上渠道就绪度用:pos_store_omg/pos_store_line_pay 为门店级凭证) * @param payeeUserId 收款方用户 ID(商家主账号;闪送下单时无收款方传 null,跳过选择与就绪度层) */ public void assertUsable(String payType, GateScope scope, Long storeId, Long payeeUserId) { if (!isUsable(payType, scope, storeId, payeeUserId)) { throw new ServiceException(MessageUtils.message("pay.method.not.available")); } } /** * 带订单类型的校验重载(031 US7):用户餐饮下单统一调用。 * 现金在此口径下按订单类型与商家勾选管控:自取/堂食查商家选择,外送拒绝, * orderType 缺失(商家建单/闪送缺省现金)与原 {@link #assertUsable} 口径一致放行。 * * @param orderType 订单类型(PosOrder.type:0外送 1自取 2堂食);无类型上下文传 null */ public void assertUsableForOrder(String payType, GateScope scope, Long storeId, Long payeeUserId, Long orderType) { if (!isUsableForOrder(payType, scope, storeId, payeeUserId, orderType)) { throw new ServiceException(MessageUtils.message("pay.method.not.available")); } } /** 现金订单类型口径的单一判定;非现金支付方式与原 isUsable 完全同路径。 */ private boolean isUsableForOrder(String payType, GateScope scope, Long storeId, Long payeeUserId, Long orderType) { if (PAY_TYPE_CASH.equals(payType)) { if (orderType == null || scope == GateScope.RIDER_FLASH) return true; if (orderType != 1L && orderType != 2L) return false; Set selected = selectedGroups(scope, payeeUserId); return selected == null || selected.contains(GROUP_CASH); } return isUsable(payType, scope, storeId, payeeUserId); } /** * 列出某维度对收款方当前可用的支付方式(结算页可选项、骑手端可选集共用)。 * 返回平台开关 ∩ 收款方选择的全部项并附就绪度;RIDER_FLASH 维度就绪度恒 true * (收款方未定或即骑手本人,线下卡是否就绪由骑手自选方式时自行把握,抢单过滤兜底)。 */ public List listAvailable(GateScope scope, Long storeId, Long payeeUserId) { return listAvailable(scope, storeId, payeeUserId, null); } /** * orderType 感知重载(031 US7):MERCHANT 维度且 orderType∈{1自取,2堂食} 时结果含现金项 * (仍受商家勾选约束);其余 orderType 或原签名口径不含现金,老客户端零变化。 */ public List listAvailable(GateScope scope, Long storeId, Long payeeUserId, Long orderType) { Map disabled = platformDisabledMap(scope); Set selected = selectedGroups(scope, payeeUserId); boolean cashVisible = scope == GateScope.MERCHANT && orderType != null && (orderType == 1L || orderType == 2L); List result = new ArrayList<>(); for (String group : SCOPE_GROUPS.get(scope)) { if (GROUP_CASH.equals(group) && !cashVisible) continue; if (disabled.containsKey(group)) continue; if (selected != null && !selected.contains(group)) continue; boolean ready = isReady(group, scope, storeId, payeeUserId); for (String payType : payTypesOf(group)) { result.add(new PayMethodOption(group, payType, ready)); } } return result; } /** 单一判定实现:assertUsable 与调用方需要的布尔语义共用,保证两口径永不分裂。 */ private boolean isUsable(String payType, GateScope scope, Long storeId, Long payeeUserId) { // 现金仅商家建单场景使用,明确不纳入管控(spec FR-006)。 if (PAY_TYPE_CASH.equals(payType)) return true; String group = PAY_TYPE_TO_GROUP.get(payType); if (group == null || !SCOPE_GROUPS.get(scope).contains(group)) return false; if (platformDisabledMap(scope).containsKey(group)) return false; Set selected = selectedGroups(scope, payeeUserId); if (selected != null && !selected.contains(group)) return false; return isReady(group, scope, storeId, payeeUserId); } /** 平台已关闭的组集合(该维度);缺行=开,仅 enabled=0 的行进集合。 */ private Map platformDisabledMap(GateScope scope) { List rows = configMapper.selectList( new QueryWrapper().eq("scope", scope.name())); Map disabled = new HashMap<>(); for (PaymentMethodConfig row : rows) { if (Integer.valueOf(0).equals(row.getEnabled())) { disabled.put(row.getMethodCode(), Boolean.TRUE); } } return disabled; } /** 收款方接受的组集合;null/空选择返回 null 表示"全部接受"(存量零行为变化)。 */ private Set selectedGroups(GateScope scope, Long payeeUserId) { if (payeeUserId == null) return null; InfoUser user = userMapper.selectById(payeeUserId); if (user == null) return null; String csv = scope == GateScope.MERCHANT ? user.getPayMethods() : user.getFlashPayMethods(); if (StringUtils.isEmpty(csv) || csv.trim().isEmpty()) return null; Set groups = new java.util.HashSet<>(); for (String part : csv.split(",")) { String value = part.trim(); if (!value.isEmpty()) groups.add(value); } return groups.isEmpty() ? null : groups; } /** * 就绪度判定:MERCHANT 维度线上渠道看门店级凭证、线下转账看商家启用卡; * RIDER_FLASH 维度恒满足(收款方未定,由骑手选择层与抢单过滤兜底)。 */ private boolean isReady(String group, GateScope scope, Long storeId, Long payeeUserId) { if (group.equals(GROUP_COD) || group.equals(GROUP_CASH)) return true; if (scope == GateScope.RIDER_FLASH) return true; switch (group) { case GROUP_CARD_OMG: return storeId != null && omgMapper.selectCount( new QueryWrapper().eq("store_id", storeId).eq("is_enabled", 1)) > 0; case GROUP_LINE_PAY: return storeId != null && linePayMapper.selectCount( new QueryWrapper().eq("store_id", storeId).eq("is_enabled", 1)) > 0; case GROUP_OFFLINE: return payeeUserId != null && bankCardMapper.selectCount( new QueryWrapper().eq("user_id", payeeUserId).eq("is_active", 1)) > 0; default: return false; } } /** 组代码 → 对外 payType 展开顺序(保持稳定展示顺序);供抢单过滤等调用方展开。 */ public static List payTypesOf(String group) { switch (group) { case GROUP_COD: return List.of("1"); case GROUP_CARD_OMG: return List.of("2", "5"); case GROUP_LINE_PAY: return List.of("3"); case GROUP_OFFLINE: return List.of("6"); case GROUP_CASH: return List.of(PAY_TYPE_CASH); default: return List.of(); } } /** 全部合法组代码(平台设置页与保存校验用)。 */ public static List allGroups() { return Arrays.asList(GROUP_COD, GROUP_CARD_OMG, GROUP_LINE_PAY, GROUP_OFFLINE); } /** 维度合法组集合(保存收款方选择时校验值域用)。 */ public static List groupsOf(GateScope scope) { return SCOPE_GROUPS.get(scope); } /** 平台开关矩阵的组集合:值域剔除 CASH——现金无平台开关行(spec FR-006/US7);选择值域仍用 {@link #groupsOf}。 */ public static List platformGroupsOf(GateScope scope) { List result = new ArrayList<>(); for (String group : groupsOf(scope)) { if (!GROUP_CASH.equals(group)) result.add(group); } return result; } /** payType → 组代码映射查询(保存/展示场景用);未知返回 null。 */ public static String groupOfPayType(String payType) { return PAY_TYPE_TO_GROUP.get(payType); } }