ソースを参照

修复LINE Pay退款状态展示并隔离支付通知异常

qmj 5 日 前
親
コミット
84635c86cb

+ 11 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayOfflineService.java

@@ -62,6 +62,8 @@ public class LinePayOfflineService {
     private final LinePayGatewayAuditService auditService;
     private final PaymentCreateGuardService createGuard;
     private final MerchantStoreAccessService merchantStoreAccessService;
+    @Autowired
+    private com.ruoyi.system.service.IPosOrderLineRefundService refundService;
 
     public LinePayOfflineService(IPosOrderService orderService,
                                  IInfoUserService userService,
@@ -176,9 +178,17 @@ public class LinePayOfflineService {
             LinePayOfflineResult notStarted = new LinePayOfflineResult();
             notStarted.setDdId(String.valueOf(order.getDdId()));
             notStarted.setStatus("NOT_STARTED");
+            notStarted.setOrderState(order.getState());
+            notStarted.setOrderPayStatus(order.getPayStatus());
             return notStarted;
         }
-        return result(selected, null);
+        LinePayOfflineResult view = result(selected, null);
+        view.setOrderState(order.getState());
+        view.setOrderPayStatus(order.getPayStatus());
+        // PAID是历史扣款事实;取消后的退款单独返回,防止App把已退款订单当成可履约新单。
+        var refund = refundService.getByPaymentId(selected.getId());
+        view.setRefundStatus(refund == null ? null : refund.getStatus());
+        return view;
     }
 
     public String reconcilePayment(Long paymentId, String source) {

+ 9 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayOrderNotificationService.java

@@ -56,7 +56,6 @@ public class LinePayOrderNotificationService {
     private void pushPaymentSuccess(PosOrder order) {
         try {
             String ddId = String.valueOf(order.getDdId());
-            String body = OrderPushBodyDto.getJson(ddId, String.valueOf(order.getState()), 0);
             InfoUser user = order.getUserId() == null ? null : infoUserService.getById(order.getUserId());
             if (user != null) {
                 // 推送常在异步线程执行(confirm 回调投递 linePayTaskExecutor),无请求级 locale,
@@ -70,6 +69,14 @@ public class LinePayOrderNotificationService {
                 pushEventService.PublisherEvent(user.getUserId(), title, content, userBody);
                 businessPushService.send(RecipientApp.USER, user.getUserId(), user.getCid(), title, content, userBody);
             }
+        } catch (Exception exception) {
+            log.error("LINE Pay user payment notification failed, ddId={}", order.getDdId(), exception);
+        }
+        // 用户消息入库、设备推送或用户资料读取失败,均不能阻止商家/骑手接收履约通知。
+        // 两条通知仍由同一个afterCommit触发,既不提前通知,也不新增重复支付通知入口。
+        try {
+            String ddId = String.valueOf(order.getDdId());
+            String body = OrderPushBodyDto.getJson(ddId, String.valueOf(order.getState()), 0);
             if (Long.valueOf(0L).equals(order.getType())) {
                 deliveryOrderNotificationService.notifyOrderAvailable(order);
             } else {
@@ -77,7 +84,7 @@ public class LinePayOrderNotificationService {
                         "no.message.push.message", "no.message.push.new.order", body, ddId, order.getType());
             }
         } catch (Exception exception) {
-            log.error("LINE Pay payment success push failed, ddId={}", order.getDdId(), exception);
+            log.error("LINE Pay fulfillment notification failed, ddId={}", order.getDdId(), exception);
         }
     }
 

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/LinePayOfflineResult.java

@@ -12,6 +12,12 @@ public class LinePayOfflineResult {
     private String transactionId;
     /** NOT_STARTED 表示尚无 Offline 流水,仅用于状态查询;其他值沿用付款结果。 */
     private String status;
+    /** GET状态查询返回订单当前状态;4为已取消,不能仅按流水PAID提示可履约。 */
+    private Long orderState;
+    /** GET状态查询返回订单支付状态:0未支付、1已支付、2已退款。 */
+    private Long orderPayStatus;
+    /** 当前Offline流水的退款状态,无退款记录时为空;不能覆盖原付款事实。 */
+    private String refundStatus;
     private Boolean reusedAttempt;
     private Date updatedAt;
 }

+ 2 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayOfflineMerchantAccessTest.java

@@ -53,6 +53,8 @@ class LinePayOfflineMerchantAccessTest {
         messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
         service = new LinePayOfflineService(orders, users, stores, credentials, payments, client, facts,
                 null, null, access);
+        org.springframework.test.util.ReflectionTestUtils.setField(service, "refundService",
+                mock(com.ruoyi.system.service.IPosOrderLineRefundService.class));
         subaccount.setMerchantOwnerId(801L);
         subaccount.setSubaccountStatus("0");
         // 分管账号允许负责多门店,单值 storeId 为空;权限来自授权关系。

+ 39 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayOfflineServiceTest.java

@@ -59,6 +59,8 @@ class LinePayOfflineServiceTest {
         facts = mock(LinePayFactService.class);
         service = new LinePayOfflineService(orders, users, stores, credentials, payments,
                 client, facts);
+        org.springframework.test.util.ReflectionTestUtils.setField(service, "refundService",
+                mock(com.ruoyi.system.service.IPosOrderLineRefundService.class));
         when(users.getOne(any(Wrapper.class))).thenReturn(merchant());
         when(orders.getOne(any(Wrapper.class))).thenReturn(order());
         when(orders.count(any(Wrapper.class))).thenReturn(1L);
@@ -69,6 +71,43 @@ class LinePayOfflineServiceTest {
                 eq("TWD"), eq("OFFLINE"), any())).thenReturn(intent());
     }
 
+    @ParameterizedTest
+    @ValueSource(strings = {"CREATED", "PROCESSING", "UNKNOWN", "RETRY_WAIT", "FAILED", "MANUAL_REVIEW", "REFUNDED"})
+    void paidAttemptIncludesCancelledOrderAndRefundFacts(String refundStatus) {
+        var refunds = mock(com.ruoyi.system.service.IPosOrderLineRefundService.class);
+        org.springframework.test.util.ReflectionTestUtils.setField(service, "refundService", refunds);
+        PosOrder current = order(); current.setState(4L);
+        current.setPayStatus("REFUNDED".equals(refundStatus) ? 2L : 1L);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(current);
+        PosOrderLinePayment paid = intent(); paid.setStatus("PAID"); paid.setPaymentMode("OFFLINE");
+        when(payments.getByDdId("DD-100")).thenReturn(java.util.List.of(paid));
+        var refund = new com.ruoyi.system.domain.PosOrderLineRefund(); refund.setStatus(refundStatus);
+        when(refunds.getByPaymentId(paid.getId())).thenReturn(refund);
+        var result = service.status(101L, "DD-100");
+        assertEquals("PAID", result.getStatus());
+        assertEquals(4L, result.getOrderState());
+        assertEquals(current.getPayStatus(), result.getOrderPayStatus());
+        assertEquals(refundStatus, result.getRefundStatus());
+        verify(refunds).getByPaymentId(paid.getId());
+        org.mockito.Mockito.verifyNoMoreInteractions(refunds);
+        org.mockito.Mockito.verifyNoInteractions(client, facts, credentials);
+    }
+
+    @Test
+    void noOfflineAttemptKeepsOrderPaymentFactAndDoesNotQueryRefund() {
+        var refunds = mock(com.ruoyi.system.service.IPosOrderLineRefundService.class);
+        org.springframework.test.util.ReflectionTestUtils.setField(service, "refundService", refunds);
+        PosOrder current = order(); current.setPayStatus(1L); current.setState(1L);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(current);
+        when(payments.getByDdId("DD-100")).thenReturn(java.util.List.of());
+        var result = service.status(101L, "DD-100");
+        assertEquals("NOT_STARTED", result.getStatus());
+        assertEquals(1L, result.getOrderPayStatus());
+        assertEquals(1L, result.getOrderState());
+        org.junit.jupiter.api.Assertions.assertNull(result.getRefundStatus());
+        org.mockito.Mockito.verifyNoInteractions(refunds, client, facts);
+    }
+
     @Test
     void noOfflineAttemptIsNormalNotStartedWithoutGatewayOrWrites() {
         when(payments.getByDdId("DD-100")).thenReturn(java.util.List.of());

+ 36 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayOrderNotificationServiceTest.java

@@ -24,6 +24,42 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 class LinePayOrderNotificationServiceTest {
+    @org.junit.jupiter.params.ParameterizedTest
+    @org.junit.jupiter.params.provider.CsvSource({"0,lookup", "1,message", "2,push"})
+    void userNotificationFailureDoesNotSkipFulfillmentNotification(long orderType, String failure) {
+        var users = mock(IInfoUserService.class);
+        var events = mock(PushEventService.class);
+        var push = mock(BusinessPushService.class);
+        var delivery = mock(DeliveryOrderNotificationService.class);
+        var merchants = mock(MerchantNotificationRouter.class);
+        var service = new LinePayOrderNotificationService(users, events, mock(OrderLogHelper.class), delivery, merchants, push);
+        InfoUser user = new InfoUser(); user.setUserId(9L);
+        when(users.getById(9L)).thenReturn(user);
+        var error = new IllegalStateException("channel unavailable");
+        if ("lookup".equals(failure)) when(users.getById(9L)).thenThrow(error);
+        if ("message".equals(failure)) org.mockito.Mockito.doThrow(error).when(events)
+                .PublisherEvent(eq(9L), anyString(), anyString(), anyString());
+        if ("push".equals(failure)) org.mockito.Mockito.doThrow(error).when(push)
+                .send(eq(RecipientApp.USER), eq(9L), eq(null), anyString(), anyString(), anyString());
+        PosOrder order = new PosOrder(); order.setDdId("DD-1"); order.setUserId(9L);
+        order.setType(orderType); order.setState(0L);
+        try (var locale = mockStatic(LocaleUtils.class); var messages = mockStatic(MessageUtils.class)) {
+            locale.when(() -> LocaleUtils.getUserLocale(9L)).thenReturn(Locale.US);
+            messages.when(() -> MessageUtils.message(anyString(), eq(Locale.US))).thenAnswer(call -> call.getArgument(0));
+            org.springframework.transaction.support.TransactionSynchronizationManager.initSynchronization();
+            try {
+                service.paymentCaptured(order);
+                org.mockito.Mockito.verifyNoInteractions(delivery, merchants);
+                org.junit.jupiter.api.Assertions.assertDoesNotThrow(() ->
+                    org.springframework.transaction.support.TransactionSynchronizationManager.getSynchronizations().forEach(sync -> sync.afterCommit()));
+                if (orderType == 0) verify(delivery).notifyOrderAvailable(order);
+                else verify(merchants).sendStoreNewOrder(eq(null), eq(null), anyString(), anyString(), anyString(), eq("DD-1"), eq(orderType));
+            } finally {
+                org.springframework.transaction.support.TransactionSynchronizationManager.clearSynchronization();
+            }
+        }
+    }
+
     @Test
     void paymentNotificationWaitsForCommitAndRollbackDoesNotNotify() {
         IInfoUserService users = mock(IInfoUserService.class);

+ 3 - 1
specs/019-line-pay/contracts/api.md

@@ -117,7 +117,9 @@ Rules:
 
 Annotations: `@Anonymous`, `@Auth`, `@RequestHeader String token`, `@RequestParam String ddId`。
 
-该接口验证 `MERCHANT` 来源和当前商家归属后只读本地状态,不在请求线程调用 LINE。Success `data` 与付款接口相同,但 `reusedAttempt` 可省略。规范状态:
+该接口验证 `MERCHANT` 来源和当前商家归属后只读本地状态,不在请求线程调用 LINE。Success `data` 保留付款流水字段,`reusedAttempt` 可省略;仅本 GET 额外填充 `orderState`(订单 state,4 为取消)、`orderPayStatus`(0 未支付 / 1 已支付 / 2 已退款)、`refundStatus`(所选 Offline paymentId 的退款状态,无记录为空)。POST 付款不填充这三个字段,需再查询当前状态。
+
+`status=PAID` 是历史扣款事实,退款后不改写。App 优先展示取消及退款信息,不能把 PAID 单独作为当前收款成功或打印依据;REFUNDED / orderPayStatus=2 为已退款,其他非空退款状态按处理中、失败或人工核对展示。只有 PAID、orderPayStatus=1、明确非取消且无退款记录时才显示当前收款成功。退款查询失败返回错误,不伪装无退款。规范状态:
 
 若订单尚无 Offline 尝试(包括只有 Online 历史/活跃尝试),返回 `code=200`、`data.status=NOT_STARTED` 和 `ddId`,支付流水字段为空;不得回传 Online 支付上下文,也不表示整单未支付或可自动重扫。
 

+ 26 - 10
specs/019-line-pay/merchant-offline-app-integration.md

@@ -16,7 +16,7 @@
 6. 前端把订单号 `ddId` 和 `oneTimeKey` 提交给扫码付款接口。
 7. 根据付款接口返回的 `status` 展示当前状态。
 8. 如果状态是处理中,定时调用状态查询接口。
-9. 只有后端返回 `PAID`,才能提示商家“收款成功”。
+9. 按第 6 节结合状态查询的订单及退款状态判断;不能只凭 `PAID` 提示当前收款成功。
 
 ## 2. 商家创建订单
 
@@ -114,7 +114,15 @@ GET /system/orderShOprate/linePay/offline/status?ddId=202608180001
 Header: token
 ```
 
-响应 `data` 与付款接口基本相同,前端继续读取 `data.status`。
+响应 `data` 保留付款流水字段,并额外返回以下当前本地信息(仅 GET 查询填充,POST 付款响应不能代替此查询):
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `orderState` | number | 订单当前 `state`;`4` 为已取消 |
+| `orderPayStatus` | number | 订单当前 `payStatus`:`0` 未支付、`1` 已支付、`2` 已退款 |
+| `refundStatus` | string/null | 所选 Offline 流水的退款状态;无对应退款记录时为空 |
+
+即使 `status=NOT_STARTED`,也返回订单状态;它不代表整单未付款。POST 返回 `PAID` 后也应调用此 GET 查询,确认当前订单及退款状态。
 
 查询时机:
 
@@ -133,9 +141,9 @@ Header: token
 | `NOT_STARTED` | 查询时尚无本地扫码付款记录 | 不显示服务器错误;刷新订单及付款方式,禁止自动重发付款或自动重扫 |
 | `AUTH_REQUIRED` | 客户需要在 LINE Pay 完成选择付款方式或认证 | 提示客户在 LINE Pay 完成操作,继续查询状态,不允许重新扫码 |
 | `PROCESSING` | 后端正在确认付款结果 | 显示“支付结果确认中”,继续查询状态,不允许重新扫码 |
-| `PAID` | 付款成功 | 停止查询,提示商家收款成功,刷新订单 |
-| `CANCELLED` | 客户取消或付款码过期 | 停止查询,允许客户生成新的 My Code 后重新扫码 |
-| `FAILED` | 付款明确失败 | 停止查询,允许客户生成新的 My Code 后重新扫码 |
+| `PAID` | 此流水曾成功扣款,退款后仍保留 | 结合下述订单和退款信息展示,不能单独作为当前收款或打印依据 |
+| `CANCELLED` | 此付款尝试取消或付款码过期 | 停止本次付款轮询,先确认订单仍可付款,再由客户生成新 My Code 重扫 |
+| `FAILED` | 此付款尝试明确失败 | 停止本次付款轮询,先确认订单仍可付款,再由客户生成新 My Code 重扫 |
 | `MANUAL_REVIEW` | 金额异常或付款结果需要人工核对 | 停止查询,提示联系平台处理,不允许重新扫码 |
 
 前端必须遵守:
@@ -143,7 +151,15 @@ Header: token
 - 扫码成功不等于付款成功。
 - HTTP 请求成功不等于付款成功。
 - 存在 `transactionId` 不等于付款成功。
-- 只有 `status="PAID"` 才是收款成功。
+- `status="PAID"` 只代表曾成功扣款。当前展示按下面的规则处理。
+
+订单和退款信息优先于付款流水状态:
+
+1. `refundStatus="REFUNDED"` 或 `orderPayStatus=2`:显示“已退款”。
+2. 其他非空 `refundStatus`:`CREATED/PROCESSING/UNKNOWN/RETRY_WAIT` 显示退款处理中或待确认;`FAILED` 显示退款失败、联系平台;`MANUAL_REVIEW` 显示退款待人工核对。未知退款状态也按待核对处理。
+3. `orderState=4`:显示订单已取消,并同时保留退款信息;取消不等于退款成功。
+4. 仅当本次 GET 查询返回 `status="PAID"`、`orderPayStatus=1`、明确的非取消 `orderState` 且无退款记录,才可展示当前收款成功。历史订单重新查询成功不应重复触发打印,打印仍按原有业务规则。
+5. 字段缺失或状态暂未一致时刷新订单,不默认当成正常已支付,不自动重扫或重发扣款。
 
 ## 7. 网络异常处理
 
@@ -179,7 +195,7 @@ Header: token
 - `code === 200`:读取 `data`。
 - `code !== 200`:展示后端返回的 `msg`。
 - 不要根据中文 `msg` 判断付款状态。
-- 付款状态只根据成功响应中的 `data.status` 判断。
+- 付款事实读取 `data.status`;当前订单及退款展示同时读取 `orderState/orderPayStatus/refundStatus`。
 
 ## 9. Sandbox 联调步骤
 
@@ -199,7 +215,7 @@ https://sandbox-web-pay.line.me/web/sandbox/payment/oneTimeKey?countryCode=TW&pa
 5. 商家 App 扫描生成的测试付款码。
 6. 调用 `/offline/pay`。
 7. 如果返回处理中,继续调用 `/offline/status`。
-8. 最终返回 `PAID` 后提示收款成功。
+8. 查询返回 `PAID` 后按第 6 节确认订单及退款状态,再提示当前收款成功。
 
 Sandbox 环境不能使用客户真实 LINE App 生成的 My Code。
 
@@ -212,8 +228,8 @@ Sandbox 环境不能使用客户真实 LINE App 生成的 My Code。
 - [ ] `oneTimeKey` 不记录、不缓存、不打印。
 - [ ] 提交付款期间禁用重复扫码。
 - [ ] `AUTH_REQUIRED` 和 `PROCESSING` 会继续查询状态。
-- [ ] 只有 `PAID` 提示收款成功。
-- [ ] `CANCELLED` 和 `FAILED` 才允许重新扫码。
+- [ ] `PAID` 结合订单及退款状态展示;已取消、退款中或已退款不误报正常收款成功。
+- [ ] 流水 `CANCELLED/FAILED` 后先刷新订单,确认未取消、未支付且可付款,再允许商家重新扫码。
 - [ ] `MANUAL_REVIEW` 不允许重新扫码。
 - [ ] 付款接口超时后不自动重发,只查询状态。
 - [ ] App 回到前台或重新进入订单页面时重新查询状态。

+ 1 - 1
specs/019-line-pay/offline-merchant-scan-design.md

@@ -130,7 +130,7 @@ GET /system/orderShOprate/linePay/offline/status?ddId=<Foodie业务订单号>
 Header: token
 ```
 
-查询接口先验证商家订单归属,再返回稳定的本地结果:
+查询接口先验证商家订单归属,再返回本地付款事实及订单/退款信息:`orderState`、`orderPayStatus`、`refundStatus`。PAID 仅表示曾扣款,退款后不改写;App 展示以订单及退款状态优先,详见商家扫码接入说明第 6 节。无 Offline 流水时返回 NOT_STARTED 和订单状态,不代表整单未支付。
 
 ```text
 AUTH_REQUIRED  客户需在 LINE Pay 完成认证

+ 6 - 0
specs/019-line-pay/plan.md

@@ -256,3 +256,9 @@ foodie-admin-vue/
 1. 在现有服务/权限测试增加无流水、仅Online流水和已有Offline流水场景,先复现报错。
 2. 仅修改status无记录分支返回NOT_STARTED;保留鉴权、对账异常与支付入口,添加注释和DTO说明。
 3. 同步规格、API及App接入说明,执行JDK21 Offline服务/权限/持久化/控制器测试及模块构建。不执行支付、数据库变更或部署。
+
+## 2026-11-04 退款状态与通知隔离修复计划
+
+1. 在OfflineResult和status补充orderState/orderPayStatus/refundStatus,用现有退款Service按所选paymentId只读查询;保持付款状态与退款状态独立,不变更数据库。
+2. 将LinePayOrderNotificationService中用户通知和履约通知拆为独立异常边界,继续事务提交后触发;不修改商家代客创建通知任务。
+3. 增加退款进行中/成功/异常、无扫码但订单已付款、用户通知各阶段抛异常的回归;核对回滚不通知和现有普通订单路由。执行定向测试、模块打包、差异检查,更新App与API契约。

+ 6 - 0
specs/019-line-pay/spec.md

@@ -679,3 +679,9 @@ Header: token
 - NOT_STARTED 不是支付失败、已支付或允许重扫的凭证;只表达查询时刻未查到本地扫码记录,不覆盖订单本身及 Online 的支付事实。
 - 原有来源、门店归属、分管账号实时授权与单店校验保持;无权限仍拒绝。已有 Offline 流水沿用原状态映射。对账任务按 paymentId 找不到记录仍保留异常。
 - 查询只读,不调用支付网关、不新增支付流水、不修改订单。App 不得因 NOT_STARTED 自动重发付款;需刷新订单并由商家明确操作,付款入口继续执行锁及活跃流水防重。
+
+## 2026-11-04 查询退款事实与支付通知隔离
+
+- Offline GET status保留status表示所选扫码流水的付款事实(退款后仍可为PAID);增加orderState、orderPayStatus和refundStatus,分别取授权订单当前state、payStatus及该paymentId的退款记录。无Offline流水也返回订单状态,无对应退款记录时refundStatus为空,不返回其他付款流水的退款信息。
+- 查询只读本地事实,不调用网关;查询失败不能伪装成无退款。付款POST的返回结构复用DTO,但这三个字段只在GET status中填充,App需查询确认当前订单状态后提示收款或触发打印。
+- 用户付款成功通知与后续履约通知分别隔离异常。用户查询、消息入库或外发异常不能跳过商家/骑手通知;履约通知异常仍不得影响已提交支付。继续沿用原订单类型分流和首次支付通知触发条件,不新增支付回调或打印触发点。

+ 8 - 0
specs/019-line-pay/tasks.md

@@ -204,3 +204,11 @@
 - [x] T071 执行定向测试、模块构建及差异检查,记录未执行的网关/App联调。
 
 验证记录:JDK21执行 `mvn -q -pl ruoyi-admin -am -Dtest=LinePayOffline*Test,LinePayOrderGuardTest -Dsurefire.failIfNoSpecifiedTests=false package` 成功,42项测试,0失败/错误/跳过,模块已打包;`git diff --check`通过。日志保存在忽略目录target/line-offline-empty-red.log及line-offline-empty-green.log。未执行真实网关扣款、App联调或部署;无需SQL。提交和推送状态以Git记录为准。
+
+## 2026-11-04 退款事实与通知隔离
+
+- [x] T072 补齐Offline查询的订单状态和退款状态,保留付款事实与鉴权。
+- [x] T073 用户推送异常与履约通知分离,维持事务提交和原路由规则。
+- [x] T074 完成相关测试、模块构建及App接入说明;不改App、不操作数据库、不发布。
+
+验证记录(2026-11-04):新增用例先在修复前复现 11 项失败;修复后使用 JDK 21 执行 `mvn -q -pl ruoyi-admin -am -Dtest=LinePayOffline*Test,LinePayOrderNotificationServiceTest,LinePayFact*Test,LinePayRefund*Test,LinePayOrderGuardTest -Dsurefire.failIfNoSpecifiedTests=false package`,实际匹配 73 项测试,全部通过,后端 JAR 构建成功。`git diff --check` 通过。日志位于本地忽略目录 `ruoyi-admin/target/line-status-notify-red.log` 和 `line-status-notify-green.log`。本轮不涉及数据库变更;未执行真实 LINE 扣款/退款、设备推送或 App 联调,未发布。