Explorar el Código

新增商家支付方式备注并更新规格和迁移说明

qmj hace 1 semana
padre
commit
e30409f81e

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/MerchantPayMethodController.java

@@ -97,6 +97,8 @@ public class MerchantPayMethodController extends BaseController {
         Map<String, Object> data = new HashMap<>();
         data.put("available", available);
         data.put("selected", selected);
+        // 展示说明由平台统一维护;普通商家/摊位主均只读,不合并到支付选择或结算规则。
+        data.put("remarks", gateService.listMerchantRemarks());
         return success(data);
     }
 

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/PayMethodConfigController.java

@@ -92,6 +92,11 @@ public class PayMethodConfigController extends BaseController {
                     || !PaymentMethodGateService.platformGroupsOf(scopeOf(item.getScope())).contains(item.getMethodCode())) {
                 throw new ServiceException(MessageUtils.message("pay.method.config.invalid"));
             }
+            // 只校验展示文本,不解析其中费率或到账天数,避免改变真实支付/结算逻辑。
+            if (item.getRemark() != null && (item.getRemark().length() > 500
+                    || (!"MERCHANT".equals(item.getScope()) && !item.getRemark().trim().isEmpty()))) {
+                throw new ServiceException(MessageUtils.message("pay.method.config.invalid"));
+            }
         }
         Date now = new Date();
         Long adminId = SecurityUtils.getUserId();
@@ -105,12 +110,19 @@ public class PayMethodConfigController extends BaseController {
                 row.setMethodCode(item.getMethodCode());
                 row.setScope(item.getScope());
                 row.setEnabled(item.getEnabled());
+                if ("MERCHANT".equals(item.getScope()) && item.getRemark() != null) {
+                    row.setRemark(item.getRemark().trim());
+                }
                 row.setUpdateBy(adminId);
                 row.setCreateTime(now);
                 row.setUpdateTime(now);
                 configMapper.insert(row);
             } else {
                 existing.setEnabled(item.getEnabled());
+                // 未提交保持原值;用空字符串清空,避免 MyBatis-Plus 忽略 null 更新而导致旧说明残留。
+                if ("MERCHANT".equals(item.getScope()) && item.getRemark() != null) {
+                    existing.setRemark(item.getRemark().trim());
+                }
                 existing.setUpdateBy(adminId);
                 existing.setUpdateTime(now);
                 configMapper.updateById(existing);

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/PayMethodConfigItemDto.java

@@ -11,4 +11,6 @@ public class PayMethodConfigItemDto {
     private String scope;
     /** 1=开 0=关。 */
     private Integer enabled;
+    /** 商家说明,最多 500 字;null=旧客户端未提交、不修改,空白文本=清空。骑手维度不使用。 */
+    private String remark;
 }

+ 218 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/PaymentMethodRemarkTest.java

@@ -0,0 +1,218 @@
+package com.ruoyi.app.pay;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.ruoyi.app.pay.dto.PayMethodConfigSaveDto;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.SecurityUtils;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PaymentMethodConfig;
+import com.ruoyi.system.domain.PosStore;
+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.PosStoreMapper;
+import com.ruoyi.system.mapper.PosStoreOmgMapper;
+import com.ruoyi.system.service.MerchantAccessContext;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.service.PaymentMethodGateService;
+import com.ruoyi.system.service.StallPaymentAccessService;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/** 031 商家备注:执行真实配置读写和商家接口,数据库访问以内存行替代,不调用外部服务。 */
+class PaymentMethodRemarkTest {
+    private final ObjectMapper json = new ObjectMapper()
+            .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
+    private final List<PaymentMethodConfig> stored = new ArrayList<>();
+    private PayMethodConfigController platform;
+    private MerchantPayMethodController merchant;
+    private PaymentMethodGateService gate;
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void messages() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
+        StaticMessageSource source = new StaticMessageSource();
+        source.addMessage("pay.method.config.invalid", Locale.getDefault(), "invalid config");
+        source.addMessage("no.action.success", Locale.getDefault(), "success");
+        factory.registerSingleton("messageSource", source);
+        new SpringUtils().postProcessBeanFactory(factory);
+    }
+
+    @AfterAll
+    static void restoreMessages() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @BeforeEach
+    void setup() {
+        PaymentMethodConfigMapper configs = mock(PaymentMethodConfigMapper.class);
+        // 故意返回其它维度,验证即使取回混合行,业务仍不得把骑手备注交给商家。
+        when(configs.selectList(any())).thenAnswer(invocation -> new ArrayList<>(stored));
+        when(configs.selectOne(any())).thenAnswer(invocation -> stored.isEmpty() ? null : stored.get(0));
+        when(configs.insert(any(PaymentMethodConfig.class))).thenAnswer(invocation -> {
+            PaymentMethodConfig row = invocation.getArgument(0);
+            row.setId((long) stored.size() + 1);
+            stored.add(row);
+            return 1;
+        });
+        when(configs.updateById(any(PaymentMethodConfig.class))).thenReturn(1);
+        platform = new PayMethodConfigController(configs);
+        InfoUserMapper users = mock(InfoUserMapper.class);
+        InfoUser owner = new InfoUser();
+        owner.setUserId(7L);
+        when(users.selectById(7L)).thenReturn(owner);
+        PosStoreMapper stores = mock(PosStoreMapper.class);
+        when(stores.selectList(any())).thenReturn(List.of());
+        InfoUser stallOwner = new InfoUser();
+        stallOwner.setUserType("4");
+        when(users.selectById(101L)).thenReturn(stallOwner);
+        PosStore stall = new PosStore();
+        stall.setId(11);
+        when(stores.selectById(11L)).thenReturn(stall);
+        InfoBankCardMapper cards = mock(InfoBankCardMapper.class);
+        PosStoreOmgMapper omg = mock(PosStoreOmgMapper.class);
+        PosStoreLinePayMapper line = mock(PosStoreLinePayMapper.class);
+        when(cards.selectCount(any())).thenReturn(0L);
+        when(omg.selectCount(any())).thenReturn(0L);
+        when(line.selectCount(any())).thenReturn(0L);
+        gate = new PaymentMethodGateService(configs, users, cards, omg, line, stores);
+        MerchantStoreAccessService access = mock(MerchantStoreAccessService.class);
+        MerchantAccessContext context = mock(MerchantAccessContext.class);
+        when(context.ownerUserId()).thenReturn(7L);
+        when(access.resolve(7L)).thenReturn(context);
+        StallPaymentAccessService stallAccess = mock(StallPaymentAccessService.class);
+        when(stallAccess.resolveStoreId(101L)).thenReturn(11L);
+        merchant = new MerchantPayMethodController(gate, users, access, stores, stallAccess);
+    }
+
+    private void save(Map<String, Object> item) {
+        PayMethodConfigSaveDto dto = json.convertValue(Map.of("items", List.of(item)), PayMethodConfigSaveDto.class);
+        try (MockedStatic<SecurityUtils> security = mockStatic(SecurityUtils.class)) {
+            security.when(SecurityUtils::getUserId).thenReturn(1L);
+            platform.save(dto);
+        }
+    }
+
+    private Map<String, Object> item(String remark) {
+        return Map.of("methodCode", "LINE_PAY", "scope", "MERCHANT", "enabled", 1, "remark", remark);
+    }
+
+    private Map<?, ?> merchantData(long userId) {
+        return (Map<?, ?>) merchant.get(JwtUtil.setToken(String.valueOf(userId), "test")).get(AjaxResult.DATA_TAG);
+    }
+
+    @Test
+    void savedRemarkReturnsInPlatformMatrixAndMerchantSettings() {
+        save(item("手續費 2.31%;\n消費後第三天入帳"));
+        assertEquals("手續費 2.31%;\n消費後第三天入帳",
+                json.valueToTree(platform.list().get(AjaxResult.DATA_TAG)).get(2).path("remark").asText(null));
+        assertEquals(Map.of("LINE_PAY", "手續費 2.31%;\n消費後第三天入帳"), merchantData(7L).get("remarks"));
+    }
+
+    @Test
+    void existingRemarkCanBeUpdatedAndCleared() {
+        save(item("原說明"));
+        save(item("新說明"));
+        assertEquals(Map.of("LINE_PAY", "新說明"), merchantData(7L).get("remarks"));
+        save(item("  "));
+        assertEquals("", json.valueToTree(stored.get(0)).path("remark").asText(null));
+        assertEquals(Map.of(), merchantData(7L).get("remarks"));
+    }
+
+    @Test
+    void oldClientSavingOnlySwitchPreservesRemark() {
+        save(item("三天入帳"));
+        save(Map.of("methodCode", "LINE_PAY", "scope", "MERCHANT", "enabled", 0));
+        assertEquals("三天入帳", json.valueToTree(stored.get(0)).path("remark").asText(null));
+        assertEquals(0, stored.get(0).getEnabled());
+    }
+
+    @Test
+    void noConfiguredRemarksReturnsEmptyMapForMerchantAndStall() {
+        assertEquals(Map.of(), merchantData(7L).get("remarks"));
+        assertEquals(Map.of(), merchantData(101L).get("remarks"));
+    }
+
+    @Test
+    void stallSeesSamePlatformRemarkAsOrdinaryMerchant() {
+        save(item("按合約費率"));
+        assertEquals(Map.of("LINE_PAY", "按合約費率"), merchantData(101L).get("remarks"));
+    }
+
+    @Test
+    void rejectsOverlongRemarkBeforeAnyWrite() {
+        assertThrows(ServiceException.class, () -> save(item("字".repeat(501))));
+        assertTrue(stored.isEmpty());
+    }
+
+    @Test
+    void invalidLaterItemPreventsWritingWholeBatch() {
+        PayMethodConfigSaveDto dto = json.convertValue(Map.of("items", List.of(item("可保存的說明"),
+                item("字".repeat(501)))), PayMethodConfigSaveDto.class);
+        assertThrows(ServiceException.class, () -> platform.save(dto));
+        assertTrue(stored.isEmpty());
+    }
+
+    @Test
+    void merchantCannotOverwritePlatformRemarkThroughSelectionSave() {
+        save(item("平台維護"));
+        com.ruoyi.app.pay.dto.MerchantPayMethodSaveDto dto = json.convertValue(
+                Map.of("methodCodes", List.of("LINE_PAY"), "remarks", Map.of("LINE_PAY", "商家改寫")),
+                com.ruoyi.app.pay.dto.MerchantPayMethodSaveDto.class);
+        merchant.save(JwtUtil.setToken("7", "test"), dto);
+        assertEquals(Map.of("LINE_PAY", "平台維護"), merchantData(7L).get("remarks"));
+    }
+
+    @Test
+    void acceptsMaximumLengthRemark() {
+        save(item("字".repeat(500)));
+        assertEquals(Map.of("LINE_PAY", "字".repeat(500)), merchantData(7L).get("remarks"));
+    }
+
+    @Test
+    void rejectsRemarkOnRiderScope() {
+        assertThrows(ServiceException.class, () -> save(Map.of("methodCode", "LINE_PAY",
+                "scope", "RIDER_FLASH", "enabled", 1, "remark", "不應寫入騎手")));
+        assertTrue(stored.isEmpty());
+    }
+
+    @Test
+    void riderRowsDoNotLeakIntoMerchantRemarks() {
+        stored.add(json.convertValue(Map.of("methodCode", "LINE_PAY", "scope", "RIDER_FLASH",
+                "enabled", 1, "remark", "騎手資料"), PaymentMethodConfig.class));
+        assertEquals(Map.of(), merchantData(7L).get("remarks"));
+    }
+
+    @Test
+    void remarkDoesNotChangeAvailabilityOrCheckoutResponse() {
+        save(item("僅說明,不是費率設定"));
+        assertFalse(gate.listAvailableForMerchantSettings(7L, List.of()).stream()
+                .filter(option -> "LINE_PAY".equals(option.getMethodCode())).findFirst().orElseThrow().isReady());
+        assertFalse(json.valueToTree(gate.listAvailable(PaymentMethodGateService.GateScope.RIDER_FLASH,
+                null, null)).toString().contains("remark"));
+    }
+}

+ 2 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PaymentMethodConfig.java

@@ -23,6 +23,8 @@ public class PaymentMethodConfig {
     private String scope;
     /** 1=开 0=关;仅存在且为 0 时才表示平台关闭。 */
     private Integer enabled;
+    /** 平台维护的商家说明(到账时间、费率等),仅 MERCHANT 使用;不参与支付或结算计算。 */
+    private String remark;
     /** 平台设置页展示排序,小的在前。 */
     private Integer sort;
     /** 最后操作的管理员用户 ID。 */

+ 17 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/PaymentMethodGateService.java

@@ -272,6 +272,23 @@ public class PaymentMethodGateService {
         return result;
     }
 
+    /**
+     * 商家设置页独立的只读说明;不扩展共用 PayMethodOption,以保持用户结算和骑手接口原样。
+     * 普通商家和摊位读取同一份平台说明,商家保存支付选择时不能改写此配置。
+     */
+    public Map<String, String> listMerchantRemarks() {
+        Map<String, String> remarks = new HashMap<>();
+        for (PaymentMethodConfig row : configMapper.selectList(new QueryWrapper<PaymentMethodConfig>()
+                .eq("scope", GateScope.MERCHANT.name()))) {
+            if (GateScope.MERCHANT.name().equals(row.getScope())
+                    && SCOPE_GROUPS.get(GateScope.MERCHANT).contains(row.getMethodCode())
+                    && StringUtils.isNotBlank(row.getRemark())) {
+                remarks.put(row.getMethodCode(), row.getRemark());
+            }
+        }
+        return remarks;
+    }
+
     /**
      * 就绪度判定:MERCHANT 维度线上渠道看门店级凭证、线下转账按普通商家或摊位归属看启用卡;
      * RIDER_FLASH 维度仅线下转账在骑手身份已知时要求启用卡。

+ 5 - 1
specs/031-pay-method-config/contracts/api.md

@@ -56,11 +56,13 @@ GET /system/payMethodConfig/list
 
 ```
 PUT /system/payMethodConfig
-Body: { items: [ { methodCode, scope, enabled } ] }
+Body: { items: [ { methodCode, scope, enabled, remark? } ] }
 ```
 
 批量 upsert;`@Log(title="支付方式设置")`;`@PreAuthorize("@ss.hasPermi('pay:method:config')")`。
 
+2026-10-09 备注:平台 GET 每行增加 `remark`,仅 `MERCHANT` 可提交非空文本,最多 500 字,支持换行。字段省略/null 保留已有值;空白文本保存为空字符串、清空说明。长度超限或骑手维度非空备注用 `pay.method.config.invalid` 拒绝整个批次;不改变开关语义。
+
 ## 2. 商家端 PC(foodie-store,token = 商家主账号/子账号)
 
 ### 2.1 支付方式设置页数据
@@ -71,6 +73,8 @@ GET /system/merchantPayMethods
 
 返回:`{ available: [ {methodCode, payType, ready} ], selected: ["COD", ...] }`——available = 平台商家维度开放集合(附就绪度标记)**追加 CASH 项(2026-09-20 变更:无平台开关、ready 恒 true,是否可用由商家勾选决定)**,selected = 商家当前选择(NULL 视为全部,返回空数组,由前端展示为全选)。**就绪度口径(2026-09-29 变更)**:普通商家调用 `listAvailableForMerchantSettings(ownerUserId, 门店IDs)`——线上渠道按商家名下门店聚合(任一门店有启用凭证即 ready=true)、线下转账看主账号启用卡、COD/现金恒 true;前端对 ready=false 项渲染「未就绪」标签。
 
+2026-10-09:商家设置 GET 在原 `available/selected` 外追加 `remarks: { "LINE_PAY": "手續費依合約;消費後第三天入帳" }`。按组代码返回平台商家维度的非空说明,缺配置返回 `{}`,前端只展示 available 中对应方式。普通商家/摊位主共用此说明;PUT 不接收或写入备注。用户结算、骑手设置及闪送共用选项结构保持不变。备注是管理员输入原文,示例不代表平台真实费率或到账承诺。
+
 ### 2.2 保存选择
 
 ```

+ 1 - 0
specs/031-pay-method-config/data-model.md

@@ -12,6 +12,7 @@
 | method_code | VARCHAR(32) NOT NULL | 支付方式代码:COD / CARD_OMG / LINE_PAY / OFFLINE_TRANSFER(**组代码**,非 payType 数值) |
 | scope | VARCHAR(32) NOT NULL | 维度:MERCHANT(商家餐饮单)/ RIDER_FLASH(骑手闪送) |
 | enabled | TINYINT NOT NULL DEFAULT 1 | 1=开 0=关 |
+| remark | VARCHAR(500) NULL | 2026-10-09:仅 MERCHANT 使用的商家只读说明,支持换行,不参与费率/结算;NULL/空=不显示 |
 | sort | INT NOT NULL DEFAULT 0 | 平台设置页展示排序 |
 | update_by | BIGINT | 最后操作管理员 |
 | update_time / create_time | DATETIME | |

+ 2 - 0
specs/031-pay-method-config/plan.md

@@ -82,6 +82,8 @@ E:\QtwCode\foodie\foodie-store\src\views\PayMethodSettings.vue   # 新:商家支
 
 ## 变更记录
 
+- **2026-10-09 商家支付方式备注(US9)**:`payment_method_config.remark VARCHAR(500) NULL`,仅 `MERCHANT` 使用。MyBatis-Plus `BaseMapper` 自动映射,无自定义 XML。Entity/平台保存 DTO/平台读写接口增加备注及长度、维度校验;省略/null 保留,空字符串清空。闸门服务增加独立 `listMerchantRemarks()`,商家设置 GET 返回 `remarks`(组代码→非空文本),不修改共用 `PayMethodOption`,保证骑手/用户结算接口不增加说明字段。平台页可编辑,商家/摊位页只读普通插值展示并保留换行,两端新增固定文案四语言。SQL 只写 `updatesql/sql.md`,部署前开发者执行。验证配置读写、清空/兼容旧请求、长度/维度隔离、摊位一致性及前端 payload/渲染。
+
 - **2026-09-30 夜市摊位支付设置(US8)**:普通商家继续使用 `info_user.pay_methods` 与用户归属银行卡;摊位新增 `pos_store.pay_methods`(NULL=全部)和 `info_bank_card.store_id`(摊位卡,`user_id` 为空)。摊位主仅凭有效登录账号的 `store_id` 解析摊位,校验账号启用、未删除、摊位未删除且 `is_stall=1`;夜市主无摊位设置权限。闸门在 MERCHANT 维度根据 `store_id` 判断摊位归属,支付方式选择和线下转账就绪度分别读摊位字段和摊位启用卡;`bankInfo` 同一归属规则。商家端复用支付设置页并向摊位主显示菜单。通用门店/摊位 JSON 不可改 `payMethods` 和 `isStall`,`editStall` 校验夜市主归属并固定支付路由字段。摊位卡新增和启用在事务内先以 `pos_store` 行锁串行化,避免多位摊位主并发产生两张首启用卡。SQL 仅登记在 `updatesql/sql.md`,由开发者手工执行后再部署代码。验证:访问隔离、同摊位共享、跨摊位隔离、结算/下单闸门与 `bankInfo`,并回归普通商家和骑手。
 
 - **2026-09-20 自取/堂食现金支付(spec US7)**:现金(payType=4)从"完全不管控、仅商家建单"调整为"纳入商家勾选(info_user.pay_methods 新增 CASH 组)、仍无平台开关与就绪度";可用场景扩展到用户自取/堂食下单(外送单直传现金被闸门拒绝)。闸门新增带 orderType 上下文的 `assertUsableForOrder` 重载,原四处调用点行为不变。无 DDL。任务见 tasks.md Phase 10(T026-T030)。

+ 10 - 0
specs/031-pay-method-config/quickstart.md

@@ -80,3 +80,13 @@ WHERE bank_name<>'' AND bank_account_no<>'' AND bank_account_name<>''
 3. A 的用户结算页、实际下单校验、`bankInfo?id=A` 只采用 A 的选择及启用卡;B 各自独立;普通商家连锁门店和骑手原路径不变。
 4. 停用 A 的摊位主、取消其 `store_id` 关联,或改用夜市主账号调用设置接口,均应拒绝。通用门店编辑请求即使附带 `payMethods`、`isStall` 也不得改变摊位支付归属;另一个夜市主调用 `editStall` 修改 A 必须拒绝。
 5. A 的两个摊位主同时新增首张银行卡,只能出现一张启用卡;并发切换启用卡后,A 仍最多一张启用卡,B 不受影响。
+
+## 9. 商家支付备注(US9,2026-10-09)
+
+1. 开发者手工执行 `updatesql/sql.md` 的商家备注 DDL 后部署;未执行时不要启动依赖新字段的服务。
+2. 平台支付矩阵分别填写信用卡/Apple Pay 与 LINE Pay 的商家备注,保存、重进应回显;平台 MERCHANT 行返回 remark,商家 GET 返回 remarks 映射。
+3. 普通商家、子账号和摊位主进入支付设置页,应看到同一平台说明;商家只可改支付选择,不能改备注。多行保留、HTML 字符按原文显示,不执行 HTML。
+4. 清空备注后保存,重新进入商家页不显示该说明;旧平台客户端只提交开关时不会清空已有备注。
+5. 骑手设置、闪送下单、用户结算结构及支付闸门不变;超过 500 字或向骑手维度提交非空备注整批拒绝。
+
+自动验证:后端 `PaymentMethodRemarkTest` 与现有回归;两个前端 `node --test tests/pay-method-remark.test.cjs`。真实数据库迁移与页面登录冒烟不由自动测试替代。

+ 7 - 0
specs/031-pay-method-config/spec.md

@@ -190,6 +190,7 @@
 - **FR-017**: 所有数据库变更 MUST 记入 `updatesql/sql.md` 由开发者统一手动执行(项目惯例)。
 - **FR-018**: 摊位支付设置与银行卡接口 MUST 根据有效摊位主的登录身份和关联摊位校验访问权,不信任客户端传入的归属 ID;停用/删除账号、未关联或已删除摊位 MUST 拒绝。
 - **FR-019**: 通用门店/摊位编辑 MUST NOT 改变用于选择支付归属的摊位标志或摊位支付设置;多个摊位主并发新增/启用银行卡时,MUST 保持每摊位最多一张启用卡。
+- **FR-020**(2026-10-09): 平台 MUST 能按商家维度的支付方式维护选填备注(最多 500 字、支持换行),用于说明到账时间、费率等;普通商家及摊位主只读查看。空备注不显示,提交空白文本清空,旧客户端未提交备注时保留原值。备注 MUST NOT 参与费率计算、支付闸门或结算,MUST NOT 展示到骑手和用户结算接口。
 
 ### Key Entities *(include if feature involves data)*
 
@@ -221,3 +222,9 @@
 - 闪送资金流维持"用户直接付骑手、平台不经手"现状,本期不引入平台代收。
 - 商家 App / 骑手 App 前端页面不在本期范围(仅后端接口);OMG / LINE Pay 凭证维持现有门店级平台管理模式。
 - 骑手确认收款为运营记录性质,不与资金到账做系统核对。
+
+## 2026-10-09 增量:商家支付方式备注(US9)
+
+平台在原支付方式矩阵增加「商家备注」输入框;每种方式对应 `MERCHANT` 配置行的 `remark`,不为骑手维度维护备注。商家支付设置页在方式选择区域下方按方式显示平台说明,普通商家与摊位主使用同一份平台文案,商家无编辑权限。
+
+验收:平台保存后重进回显;商家下次读取显示相同内容;换行和标点保留,HTML 作为普通文本;清空即隐藏;超过 500 字或给骑手维度提交非空备注时拒绝整批保存;不影响未就绪提示、勾选、用户结算和骑手支付方式接口。界面标题和提示提供四语言,管理员填写的备注按原文展示、不自动翻译,不硬编码费率及到账天数。迁移由开发者手动执行。

+ 8 - 0
specs/031-pay-method-config/tasks.md

@@ -185,3 +185,11 @@ description: "Task list for 031-pay-method-config"
 - [x] T038 接通 `bankInfo` 的摊位启用卡、商家端摊位主菜单,补必要注释;页面复用现有四语言文案。
 - [x] T039 运行定向测试、模块构建及前端检查,核对两个仓库 diff 与部署前 SQL 手工执行要求;数据库迁移和真实并发冒烟由开发者部署前执行。
 - [x] T040 代码审查收口:通用摊位编辑授权与不可变支付归属字段;摊位首卡新增/启用串行化,补回归测试。
+
+## Phase 13:商家支付方式备注(2026-10-09,US9)
+
+- [x] T041 添加平台保存回显、清空、旧请求兼容、500 字边界及骑手隔离失败测试;前端添加备注读写与纯文本渲染测试。
+- [x] T042 扩展平台备注字段/DTO/校验和商家只读 `remarks`;不改变支付闸门、结算、共用选项及骑手接口。
+- [x] T043 平台矩阵新增商家备注输入框、商家与摊位页面显示说明,补两端四语言标题/提示,保留原有换行风格。
+- [x] T044 更新现有规格、计划、数据模型、接口与手工迁移 SQL。
+- [x] T045 提交前后端全量回归 1001 项通过(新增备注测试共 12 项),支付相关定向测试及 JDK 21 模块打包通过;平台前端行为测试 13 项、商家前端 2 项通过,商家 `build:test` 成功,两端修改页面模板/脚本及四语言检查通过,三仓库 diff 已核对。平台完整构建未执行(未安装 node_modules);真实数据库迁移和登录浏览器冒烟未执行,由开发者部署前完成。

+ 9 - 0
updatesql/sql.md

@@ -1883,3 +1883,12 @@ ALTER TABLE info_bank_card
 ALTER TABLE user_billing
   ADD COLUMN order_source VARCHAR(16) DEFAULT NULL COMMENT '订单来源:FLASH闪送 FOOD外卖;历史及非订单账单可为空' AFTER dd_id;
 ```
+
+## 2026-10-09 031 商家支付方式备注(US9)
+
+部署新代码前由开发者手动执行。仅用于平台维护、普通商家/摊位主只读展示;不修改费率、到账天数或支付开关。存量 NULL 无说明,不初始化示例费率或到账承诺。
+
+```sql
+ALTER TABLE payment_method_config
+  ADD COLUMN remark VARCHAR(500) DEFAULT NULL COMMENT '商家维度支付说明:到账时间费率等;仅展示不参与结算' AFTER enabled;
+```