Ver código fonte

新增骑手收入账单查询与前端接入文档

支持按完整起止时间查询收入汇总及分类分页,区分外卖和闪送订单数。直接返回账单订单号供原订单查询,保留本人权限校验。同步规格、国际化提示和定向测试。
qmj 4 dias atrás
pai
commit
ddcc927f2d

+ 41 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/RiderIncomeController.java

@@ -0,0 +1,41 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.AuthContext;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Anonymous
+@RestController
+@RequestMapping("/Billing/billing/riderIncome")
+public class RiderIncomeController extends BaseController {
+    private final RiderIncomeService service;
+
+    public RiderIncomeController(RiderIncomeService service) {
+        this.service = service;
+    }
+
+    @Auth
+    @GetMapping
+    public AjaxResult list(@RequestHeader String token,
+                           @RequestParam(required = false) String startTime,
+                           @RequestParam(required = false) String endTime,
+                           @RequestParam(defaultValue = "ALL") String orderSource,
+                           @RequestParam(defaultValue = "1") Integer pageNum,
+                           @RequestParam(defaultValue = "20") Integer pageSize) {
+        return success(service.list(AuthContext.requireUserId(), startTime, endTime, orderSource, pageNum, pageSize));
+    }
+
+    @Auth
+    @GetMapping("/{billId}")
+    public AjaxResult detail(@RequestHeader String token, @PathVariable Long billId) {
+        return success(service.detail(AuthContext.requireUserId(), billId));
+    }
+}

+ 89 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/RiderIncomeService.java

@@ -0,0 +1,89 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.app.pay.dto.RiderIncomePage;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.vo.RiderIncomeItem;
+import com.ruoyi.system.domain.vo.RiderIncomeSummary;
+import com.ruoyi.system.mapper.RiderIncomeMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Isolation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.DateTimeException;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.ResolverStyle;
+import java.util.Set;
+
+@Service
+public class RiderIncomeService {
+    private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss")
+            .withResolverStyle(ResolverStyle.STRICT);
+    private final RiderIncomeMapper mapper;
+    private final IInfoUserService users;
+
+    public RiderIncomeService(RiderIncomeMapper mapper, IInfoUserService users) {
+        this.mapper = mapper;
+        this.users = users;
+    }
+
+    @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
+    public RiderIncomePage list(Long riderId, String startTime, String endTime, String orderSource,
+                                Integer pageNum, Integer pageSize) {
+        requireRider(riderId);
+        if (orderSource == null || !Set.of("ALL", "FOOD", "FLASH").contains(orderSource)
+                || pageNum == null || pageNum < 1 || pageSize == null || pageSize < 1 || pageSize > 100) {
+            throw fail("rider.income.query.invalid");
+        }
+        if ((startTime == null) != (endTime == null)) {
+            throw fail("rider.income.query.invalid");
+        }
+        if (startTime != null) {
+            try {
+                String format = "\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}";
+                if (!startTime.matches(format) || !endTime.matches(format)) {
+                    throw fail("rider.income.query.invalid");
+                }
+                LocalDateTime start = LocalDateTime.parse(startTime, TIME_FORMAT);
+                LocalDateTime end = LocalDateTime.parse(endTime, TIME_FORMAT);
+                if (start.isAfter(end) || start.getYear() < 1000) {
+                    throw fail("rider.income.query.invalid");
+                }
+            } catch (DateTimeException exception) {
+                throw fail("rider.income.query.invalid");
+            }
+        }
+        RiderIncomeSummary summary = mapper.summarize(riderId, startTime, endTime);
+        long total = switch (orderSource) {
+            case "FOOD" -> summary.getFoodOrderCount();
+            case "FLASH" -> summary.getFlashOrderCount();
+            default -> summary.getTotalOrderCount();
+        };
+        var rows = mapper.selectPage(riderId, startTime, endTime, orderSource,
+                (pageNum.longValue() - 1) * pageSize, pageSize);
+        return new RiderIncomePage(startTime, endTime, summary, total, pageNum, pageSize, rows);
+    }
+
+    public RiderIncomeItem detail(Long riderId, Long billId) {
+        requireRider(riderId);
+        if (billId == null || billId <= 0) throw fail("rider.income.not.found");
+        RiderIncomeItem item = mapper.selectDetail(riderId, billId);
+        if (item == null) throw fail("rider.income.not.found");
+        return item;
+    }
+
+    private void requireRider(Long riderId) {
+        InfoUser rider = riderId == null ? null : users.selectInfoUserByUserId(riderId);
+        if (rider == null || !"2".equals(rider.getUserType())
+                || !"0".equals(rider.getStatus()) || !"0".equals(rider.getDelFlag())) {
+            throw fail("rider.income.access.denied");
+        }
+    }
+
+    private ServiceException fail(String key) {
+        return new ServiceException(MessageUtils.message(key));
+    }
+}

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

@@ -0,0 +1,12 @@
+package com.ruoyi.app.pay.dto;
+
+import com.ruoyi.system.domain.vo.RiderIncomeItem;
+import com.ruoyi.system.domain.vo.RiderIncomeSummary;
+
+import java.util.List;
+
+/** summary 为整个查询范围,total 仅为当前分类的明细条数。 */
+public record RiderIncomePage(String startTime, String endTime,
+                              RiderIncomeSummary summary, long total, int pageNum, int pageSize,
+                              List<RiderIncomeItem> list) {
+}

+ 5 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -528,3 +528,8 @@ invoice.print.tooLarge=发票明细过长,无法生成打印文件
 invoice.print.preparing=发票打印文件正在准备,请稍后重试
 
 app.dict.type.invalid=字典类型不能为空且不能超过100个字符
+
+# Rider income statements
+rider.income.query.invalid=查詢參數無效,請檢查起止時間(年月日時分秒)、訂單類型及分頁
+rider.income.access.denied=僅啟用的騎手帳號可查看收入帳單
+rider.income.not.found=收入帳單不存在

+ 5 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -514,3 +514,8 @@ invoice.print.tooLarge=Invoice details exceed the supported print size.
 invoice.print.preparing=Invoice print files are being prepared. Please try again shortly.
 
 app.dict.type.invalid=Dictionary type is required and must not exceed 100 characters
+
+# Rider income statements
+rider.income.query.invalid=Invalid query. Check start and end times (yyyy-MM-dd HH:mm:ss), order type and pagination.
+rider.income.access.denied=Only active rider accounts can view income statements.
+rider.income.not.found=Income statement not found.

+ 5 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -497,3 +497,8 @@ no.oauth.device.phone.changed=โปรดเข้าสู่ระบบด
 no.oauth.binding.conflict=การเชื่อมโยงบัญชีภายนอกเปลี่ยนแปลงแล้ว โปรดเข้าสู่ระบบอีกครั้ง
 
 app.dict.type.invalid=ต้องระบุประเภทพจนานุกรมและต้องไม่เกิน 100 อักขระ
+
+# Rider income statements
+rider.income.query.invalid=ข้อมูลค้นหาไม่ถูกต้อง โปรดตรวจสอบเวลาเริ่มต้นและสิ้นสุด (yyyy-MM-dd HH:mm:ss) ประเภทคำสั่งซื้อ และการแบ่งหน้า
+rider.income.access.denied=เฉพาะบัญชีไรเดอร์ที่ใช้งานอยู่เท่านั้นที่ดูรายการรายได้ได้
+rider.income.not.found=ไม่พบรายการรายได้

+ 5 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -52,3 +52,8 @@ invoice.print.tooLarge=Chi tiết hóa đơn vượt quá kích thước in đư
 invoice.print.preparing=Tệp in hóa đơn đang được chuẩn bị. Vui lòng thử lại sau.
 
 app.dict.type.invalid=Loại từ điển là bắt buộc và không được vượt quá 100 ký tự
+
+# Rider income statements
+rider.income.query.invalid=Tham số không hợp lệ. Vui lòng kiểm tra thời gian bắt đầu và kết thúc (yyyy-MM-dd HH:mm:ss), loại đơn và phân trang.
+rider.income.access.denied=Chỉ tài khoản tài xế đang hoạt động mới được xem thu nhập.
+rider.income.not.found=Không tìm thấy bản ghi thu nhập.

+ 5 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -516,3 +516,8 @@ invoice.print.tooLarge=发票明细过长,无法生成打印文件
 invoice.print.preparing=发票打印文件正在准备,请稍后重试
 
 app.dict.type.invalid=字典类型不能为空且不能超过100个字符
+
+# Rider income statements
+rider.income.query.invalid=查询参数无效,请检查起止时间(年月日时分秒)、订单类型及分页
+rider.income.access.denied=仅启用的骑手账号可查看收入账单
+rider.income.not.found=收入账单不存在

+ 5 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -515,3 +515,8 @@ invoice.print.tooLarge=發票明細過長,無法產生列印檔案
 invoice.print.preparing=發票列印檔案正在準備,請稍後重試
 
 app.dict.type.invalid=字典類型不能為空且不能超過100個字元
+
+# Rider income statements
+rider.income.query.invalid=查詢參數無效,請檢查起止時間(年月日時分秒)、訂單類型及分頁
+rider.income.access.denied=僅啟用的騎手帳號可查看收入帳單
+rider.income.not.found=收入帳單不存在

+ 85 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/RiderIncomeControllerTest.java

@@ -0,0 +1,85 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.app.pay.dto.RiderIncomePage;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.vo.RiderIncomeItem;
+import com.ruoyi.system.domain.vo.RiderIncomeSummary;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.AuthContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+class RiderIncomeControllerTest {
+    private final RiderIncomeService service = mock(RiderIncomeService.class);
+    private MockMvc mvc;
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeEach void setUp() {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenReturn("ok");
+        mvc = MockMvcBuilders.standaloneSetup(new RiderIncomeController(service)).build();
+    }
+
+    @AfterEach void tearDown() { messages.close(); }
+
+    @Test
+    void listDefaultsAndAuthenticatedIdentityAreExplicit() throws Exception {
+        var summary = new RiderIncomeSummary(); summary.setTotalIncome(BigDecimal.ZERO);
+        when(service.list(7L, null, null, "ALL", 1, 20)).thenReturn(
+                new RiderIncomePage(null, null, summary, 0, 1, 20, List.of()));
+        mvc.perform(get("/Billing/billing/riderIncome").header("token", "valid")
+                        .requestAttr(AuthContext.USER_ID_ATTRIBUTE, 7L).param("riderId", "999"))
+                .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.summary.totalIncome").value(0))
+                .andExpect(jsonPath("$.data.periodOptions").doesNotExist())
+                .andExpect(jsonPath("$.data.list").isEmpty());
+        verify(service).list(7L, null, null, "ALL", 1, 20);
+    }
+
+    @Test
+    void listBindsExplicitFiltersAndDetailUsesBillId() throws Exception {
+        mvc.perform(get("/Billing/billing/riderIncome").header("token", "valid")
+                        .requestAttr(AuthContext.USER_ID_ATTRIBUTE, 7L)
+                        .param("startTime", "2026-11-01 00:00:00").param("endTime", "2026-11-30 23:59:59")
+                        .param("orderSource", "FLASH").param("pageNum", "2").param("pageSize", "10"))
+                .andExpect(status().isOk());
+        verify(service).list(7L, "2026-11-01 00:00:00", "2026-11-30 23:59:59", "FLASH", 2, 10);
+        var item = new RiderIncomeItem(); item.setBillId(90L); item.setIncomeAmount(new BigDecimal("120.00"));
+        item.setOrderNo("FD202611050001"); item.setOrderSource("FLASH");
+        when(service.detail(7L, 90L)).thenReturn(item);
+        mvc.perform(get("/Billing/billing/riderIncome/90").header("token", "valid")
+                        .requestAttr(AuthContext.USER_ID_ATTRIBUTE, 7L))
+                .andExpect(status().isOk()).andExpect(jsonPath("$.data.billId").value(90))
+                .andExpect(jsonPath("$.data.incomeAmount").value(120))
+                .andExpect(jsonPath("$.data.orderNo").value("FD202611050001"))
+                .andExpect(jsonPath("$.data.orderSource").value("FLASH"))
+                .andExpect(jsonPath("$.data.orderId").doesNotExist())
+                .andExpect(jsonPath("$.data.tipAmount").doesNotExist())
+                .andExpect(jsonPath("$.data.walletBalance").doesNotExist());
+    }
+
+    @Test
+    void tokenIsRequiredAndBothEndpointsUseAuthAspect() throws Exception {
+        mvc.perform(get("/Billing/billing/riderIncome")).andExpect(status().isBadRequest());
+        mvc.perform(get("/Billing/billing/riderIncome/1")).andExpect(status().isBadRequest());
+        verifyNoInteractions(service);
+        assertTrue(RiderIncomeController.class.isAnnotationPresent(Anonymous.class));
+        assertTrue(RiderIncomeController.class.getMethod("list", String.class, String.class, String.class,
+                String.class, Integer.class, Integer.class).isAnnotationPresent(Auth.class));
+        assertTrue(RiderIncomeController.class.getMethod("detail", String.class, Long.class).isAnnotationPresent(Auth.class));
+    }
+}

+ 162 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/RiderIncomeMapperTest.java

@@ -0,0 +1,162 @@
+package com.ruoyi.app.pay;
+
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.mapping.Environment;
+import org.apache.ibatis.session.Configuration;
+import org.apache.ibatis.session.SqlSession;
+import org.apache.ibatis.session.SqlSessionFactory;
+import org.apache.ibatis.session.SqlSessionFactoryBuilder;
+import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
+import org.h2.jdbcx.JdbcDataSource;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.BeanWrapperImpl;
+
+import java.math.BigDecimal;
+import java.sql.Connection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class RiderIncomeMapperTest {
+    private static final String NS = "com.ruoyi.system.mapper.RiderIncomeMapper.";
+    private SqlSessionFactory factory;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        var source = new JdbcDataSource();
+        source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
+        try (Connection connection = source.getConnection(); var sql = connection.createStatement()) {
+            sql.execute("CREATE TABLE user_billing (id BIGINT PRIMARY KEY, user_id BIGINT, user_type VARCHAR(8), "
+                    + "type VARCHAR(8), state VARCHAR(8), dd_id VARCHAR(64), order_source VARCHAR(16), "
+                    + "amount DOUBLE, behalf_amount DOUBLE, cretim TIMESTAMP)");
+            sql.execute("INSERT INTO user_billing VALUES "
+                    + "(1,7,'2','0','0','same',NULL,60.10,300,'2026-01-01 00:00:00'),"
+                    + "(2,7,'2','0','0','same','FLASH',120,0,'2026-01-01 12:00:00'),"
+                    + "(3,7,'2','0','0','missing','',0,0,'2026-01-01 12:00:00'),"
+                    + "(4,8,'2','0','0','other','FOOD',999,0,'2026-01-01 12:00:00'),"
+                    + "(5,7,'2','2','0','withdraw','FOOD',999,0,'2026-01-01 12:00:00'),"
+                    + "(6,7,'2','0','1','void','FOOD',999,0,'2026-01-01 12:00:00'),"
+                    + "(7,7,'1','0','0','merchant','FOOD',999,0,'2026-01-01 12:00:00'),"
+                    + "(8,7,'2','0','0','outside','FOOD',999,0,'2026-01-02 00:00:00'),"
+                    + "(9,7,'2','0','0',NULL,'FOOD',999,0,'2026-01-01 12:00:00'),"
+                    + "(10,7,'2','0','0','unknown','OTHER',999,0,'2026-01-01 12:00:00'),"
+                    + "(11,7,'2','0','0','foreign','FOOD',0.20,0,'2026-01-01 23:59:59'),"
+                    + "(12,7,'2','0','0','before','FOOD',999,0,'2025-12-31 23:59:59')");
+        }
+        var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
+        String resource = "mapper/Billing/RiderIncomeMapper.xml";
+        try (var input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            assertNotNull(input, "骑手收入 Mapper 尚未实现");
+            new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
+        }
+        factory = new SqlSessionFactoryBuilder().build(configuration);
+    }
+
+    private Map<String, Object> params(String source) {
+        var params = new HashMap<String, Object>();
+        params.put("riderId", 7L);
+        params.put("startTime", "2026-01-01 00:00:00");
+        params.put("endTime", "2026-01-01 23:59:59");
+        params.put("orderSource", source);
+        params.put("offset", 0L);
+        params.put("size", 100);
+        return params;
+    }
+
+    private Object value(Object row, String field) {
+        return new BeanWrapperImpl(row).getPropertyValue(field);
+    }
+
+    @Test
+    void summaryUsesOnlyOwnValidIncomeAndIgnoresCategoryAndCollection() {
+        try (SqlSession session = factory.openSession()) {
+            for (String source : List.of("ALL", "FOOD", "FLASH")) {
+                Object result = session.selectOne(NS + "summarize", params(source));
+                assertEquals(new BigDecimal("180.30"), value(result, "totalIncome"));
+                assertEquals(4L, value(result, "totalOrderCount"));
+                assertEquals(3L, value(result, "foodOrderCount"));
+                assertEquals(1L, value(result, "flashOrderCount"));
+            }
+        }
+    }
+
+    @Test
+    void listSeparatesSameOrderNumbersAndPaginatesWithStableTimeAndIdOrder() {
+        try (SqlSession session = factory.openSession()) {
+            List<Object> all = session.selectList(NS + "selectPage", params("ALL"));
+            assertEquals(List.of(11L, 3L, 2L, 1L), all.stream().map(x -> value(x, "billId")).toList());
+            assertEquals("foreign", value(all.get(0), "orderNo"));
+            assertEquals("missing", value(all.get(1), "orderNo"), "无需订单表也能读取历史账单");
+            assertEquals("FOOD", value(all.get(1), "orderSource"));
+            assertEquals("same", value(all.get(2), "orderNo"));
+            assertEquals("FLASH", value(all.get(2), "orderSource"));
+            assertEquals("same", value(all.get(3), "orderNo"));
+            assertEquals("FOOD", value(all.get(3), "orderSource"));
+            assertEquals(new BigDecimal("300.00"), value(all.get(3), "collectedAmount"));
+            List<Object> food = session.selectList(NS + "selectPage", params("FOOD"));
+            assertEquals(3, food.size());
+            List<Object> flash = session.selectList(NS + "selectPage", params("FLASH"));
+            assertEquals(1, flash.size());
+            assertEquals(new BigDecimal("120.00"), value(flash.get(0), "incomeAmount"));
+            Map<String, Object> page = params("ALL"); page.put("offset", 1L); page.put("size", 2);
+            List<Object> rows = session.selectList(NS + "selectPage", page);
+            assertEquals(List.of(3L, 2L), rows.stream().map(x -> value(x, "billId")).toList());
+        }
+    }
+
+    @Test
+    void allTimeIncludesHistoryOutsideDailyRangeButStillExcludesOtherAccounts() {
+        try (SqlSession session = factory.openSession()) {
+            var params = params("ALL"); params.put("startTime", null); params.put("endTime", null);
+            Object result = session.selectOne(NS + "summarize", params);
+            assertEquals(new BigDecimal("2178.30"), value(result, "totalIncome"));
+            assertEquals(6L, value(result, "totalOrderCount"));
+            assertEquals(5L, value(result, "foodOrderCount"));
+            assertEquals(1L, value(result, "flashOrderCount"));
+            assertEquals(6, session.selectList(NS + "selectPage", params).size());
+        }
+    }
+
+    @Test
+    void exactSecondRangeIncludesBothEndpointsWithoutExpandingToWholeDay() {
+        try (SqlSession session = factory.openSession()) {
+            var params = params("ALL");
+            params.put("startTime", "2026-01-01 12:00:00");
+            params.put("endTime", "2026-01-01 12:00:00");
+            List<Object> rows = session.selectList(NS + "selectPage", params);
+            assertEquals(List.of(3L, 2L), rows.stream().map(x -> value(x, "billId")).toList());
+            Object sum = session.selectOne(NS + "summarize", params);
+            assertEquals(new BigDecimal("120.00"), value(sum, "totalIncome"));
+            assertEquals(2L, value(sum, "totalOrderCount"));
+        }
+    }
+
+    @Test
+    void detailScopesByRiderAndRejectsNonIncome() {
+        try (SqlSession session = factory.openSession()) {
+            for (long id : List.of(4L, 5L, 6L, 7L, 9L, 10L, 999L)) {
+                assertNull(session.selectOne(NS + "selectDetail", Map.of("riderId", 7L, "billId", id)));
+            }
+            Object row = session.selectOne(NS + "selectDetail", Map.of("riderId", 7L, "billId", 1L));
+            assertEquals(new BigDecimal("60.10"), value(row, "incomeAmount"));
+            assertEquals("same", value(row, "orderNo"));
+        }
+    }
+
+    @Test
+    void noDataReturnsZeroSummaryAndEmptyPage() {
+        try (SqlSession session = factory.openSession()) {
+            var params = params("ALL"); params.put("riderId", 999L);
+            Object result = session.selectOne(NS + "summarize", params);
+            assertEquals(new BigDecimal("0.00"), value(result, "totalIncome"));
+            assertEquals(0L, value(result, "totalOrderCount"));
+            assertEquals(0L, value(result, "foodOrderCount"));
+            assertEquals(0L, value(result, "flashOrderCount"));
+            assertTrue(session.selectList(NS + "selectPage", params).isEmpty());
+        }
+    }
+}

+ 155 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/RiderIncomeServiceTest.java

@@ -0,0 +1,155 @@
+package com.ruoyi.app.pay;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.vo.RiderIncomeItem;
+import com.ruoyi.system.domain.vo.RiderIncomeSummary;
+import com.ruoyi.system.mapper.RiderIncomeMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class RiderIncomeServiceTest {
+    private final RiderIncomeMapper mapper = mock(RiderIncomeMapper.class);
+    private final IInfoUserService users = mock(IInfoUserService.class);
+    private final RiderIncomeService service = new RiderIncomeService(mapper, users);
+    private MockedStatic<MessageUtils> messages;
+    private InfoUser rider;
+    private RiderIncomeSummary summary;
+
+    @BeforeEach
+    void setUp() {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+        rider = new InfoUser(); rider.setUserId(7L); rider.setUserType("2");
+        rider.setStatus("0"); rider.setDelFlag("0");
+        when(users.selectInfoUserByUserId(7L)).thenReturn(rider);
+        summary = new RiderIncomeSummary(); summary.setTotalIncome(new BigDecimal("1250.00"));
+        summary.setTotalOrderCount(18L); summary.setFoodOrderCount(12L); summary.setFlashOrderCount(6L);
+        when(mapper.summarize(eq(7L), anyString(), anyString())).thenReturn(summary);
+        when(mapper.selectPage(eq(7L), anyString(), anyString(), anyString(), anyLong(), anyInt()))
+                .thenReturn(List.of());
+    }
+
+    @AfterEach void tearDown() { messages.close(); }
+
+    @Test
+    void categoriesOnlyChangeListAndTotalNotTopSummary() {
+        for (String source : List.of("ALL", "FOOD", "FLASH")) {
+            var result = service.list(7L, "2026-11-05 00:00:00", "2026-11-05 23:59:59", source, 2, 10);
+            assertSame(summary, result.summary());
+            assertEquals(source.equals("ALL") ? 18L : source.equals("FOOD") ? 12L : 6L, result.total());
+            verify(mapper).selectPage(7L, "2026-11-05 00:00:00", "2026-11-05 23:59:59", source, 10L, 10);
+        }
+        verify(mapper, times(3)).summarize(7L, "2026-11-05 00:00:00", "2026-11-05 23:59:59");
+    }
+
+    @Test
+    void allTimeHasNoTimeBoundsAndStillKeepsSummaryIndependent() {
+        when(mapper.summarize(7L, null, null)).thenReturn(summary);
+        when(mapper.selectPage(7L, null, null, "FLASH", 0L, 20)).thenReturn(List.of());
+        var result = service.list(7L, null, null, "FLASH", 1, 20);
+        assertNull(result.startTime()); assertNull(result.endTime());
+        assertSame(summary, result.summary()); assertEquals(6L, result.total());
+        verify(mapper).selectPage(7L, null, null, "FLASH", 0L, 20);
+    }
+
+    @Test
+    void explicitCrossYearRangeIsNotReinterpreted() {
+        var result = service.list(7L, "2025-12-29 00:00:00", "2026-01-04 23:59:59", "ALL", 1, 20);
+        assertEquals("2025-12-29 00:00:00", result.startTime());
+        assertEquals("2026-01-04 23:59:59", result.endTime());
+        verify(mapper).summarize(7L, "2025-12-29 00:00:00", "2026-01-04 23:59:59");
+    }
+
+    @Test
+    void leapDateAndYearEndArePassedThroughWithoutAddingADay() {
+        service.list(7L, "2024-02-01 00:00:00", "2024-02-29 23:59:59", "ALL", 1, 20);
+        verify(mapper).summarize(7L, "2024-02-01 00:00:00", "2024-02-29 23:59:59");
+        service.list(7L, "9999-12-31 00:00:00", "9999-12-31 23:59:59", "ALL", 1, 20);
+        verify(mapper).summarize(7L, "9999-12-31 00:00:00", "9999-12-31 23:59:59");
+    }
+
+    @Test
+    void arbitraryTimeRangeIsUsedExactlyAndResponseHasNoPeriodOptions() {
+        var result = service.list(7L, "2026-11-05 12:30:15", "2026-11-05 18:45:30", "ALL", 1, 20);
+        assertEquals("2026-11-05 12:30:15", result.startTime()); assertEquals("2026-11-05 18:45:30", result.endTime());
+        verify(mapper).summarize(7L, "2026-11-05 12:30:15", "2026-11-05 18:45:30");
+        var json = new ObjectMapper().valueToTree(result);
+        assertFalse(json.has("period")); assertFalse(json.has("periodOptions"));
+        assertFalse(json.has("startDate")); assertFalse(json.has("endDate"));
+    }
+
+    @Test
+    void invalidTimesRangesCategoriesAndPaginationAreRejectedBeforeSql() {
+        String valid = "2026-11-05 12:00:00";
+        for (String time : List.of("2026-02-30 00:00:00", "2026-1-1 00:00:00", "", "2026-11-05",
+                "2026-11-05 23:59", "2026-11-05 24:00:00", "2026-11-05 12:60:00", "2026-11-05 12:00:60",
+                "2026-11-05 12:00:00.123", "2026-11-05 12:00:00' OR 1=1")) {
+            assertThrows(ServiceException.class, () -> service.list(7L, time, valid, "ALL", 1, 20));
+            assertThrows(ServiceException.class, () -> service.list(7L, valid, time, "ALL", 1, 20));
+        }
+        assertThrows(ServiceException.class, () -> service.list(7L, null, valid, "ALL", 1, 20));
+        assertThrows(ServiceException.class, () -> service.list(7L, valid, null, "ALL", 1, 20));
+        assertThrows(ServiceException.class, () -> service.list(7L, "2026-11-05 12:00:01", valid, "ALL", 1, 20));
+        assertThrows(ServiceException.class, () -> service.list(7L, "0999-01-01 00:00:00", valid, "ALL", 1, 20));
+        assertThrows(ServiceException.class, () -> service.list(7L, valid, valid, "OTHER", 1, 20));
+        assertThrows(ServiceException.class, () -> service.list(7L, valid, valid, "ALL", 0, 20));
+        assertThrows(ServiceException.class, () -> service.list(7L, valid, valid, "ALL", 1, 0));
+        assertThrows(ServiceException.class, () -> service.list(7L, valid, valid, "ALL", 1, 101));
+        verifyNoInteractions(mapper);
+    }
+
+    @Test
+    void nonRidersDeletedDisabledAndMissingAccountsCannotRead() {
+        for (String type : List.of("0", "1", "3", "4", "5")) {
+            rider.setUserType(type);
+            assertThrows(ServiceException.class, () -> service.list(7L, null, null, "ALL", 1, 20));
+            assertThrows(ServiceException.class, () -> service.detail(7L, 1L));
+        }
+        rider.setUserType("2"); rider.setStatus("1");
+        assertThrows(ServiceException.class, () -> service.detail(7L, 1L));
+        rider.setStatus("0"); rider.setDelFlag("2");
+        assertThrows(ServiceException.class, () -> service.detail(7L, 1L));
+        when(users.selectInfoUserByUserId(7L)).thenReturn(null);
+        assertThrows(ServiceException.class, () -> service.detail(7L, 1L));
+        verifyNoInteractions(mapper);
+    }
+
+    @Test
+    void detailUsesAuthenticatedRiderAndHidesUnavailableRecords() {
+        var item = new RiderIncomeItem(); item.setBillId(1L);
+        when(mapper.selectDetail(7L, 1L)).thenReturn(item);
+        assertSame(item, service.detail(7L, 1L));
+        assertEquals("rider.income.not.found", assertThrows(ServiceException.class,
+                () -> service.detail(7L, 2L)).getMessage());
+        assertThrows(ServiceException.class, () -> service.detail(7L, 0L));
+    }
+
+    @Test
+    void largePageOffsetDoesNotOverflowInteger() {
+        service.list(7L, "2026-11-05 00:00:00", "2026-11-05 23:59:59", "ALL", Integer.MAX_VALUE, 100);
+        verify(mapper).selectPage(7L, "2026-11-05 00:00:00", "2026-11-05 23:59:59", "ALL", 214748364600L, 100);
+    }
+
+    @Test
+    void responseHasNoWalletWithdrawalTipOrSettlementFields() throws Exception {
+        var item = new RiderIncomeItem(); item.setIncomeAmount(new BigDecimal("120.00"));
+        var json = new ObjectMapper().valueToTree(item);
+        assertEquals(6, json.size());
+        assertTrue(json.has("billId")); assertTrue(json.has("orderNo")); assertTrue(json.has("orderSource"));
+        assertFalse(json.has("orderId")); assertTrue(json.has("incomeTime")); assertTrue(json.has("collectedAmount"));
+        assertEquals(0, new BigDecimal("120.00").compareTo(json.get("incomeAmount").decimalValue()));
+    }
+}

+ 19 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/RiderIncomeItem.java

@@ -0,0 +1,19 @@
+package com.ruoyi.system.domain.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/** 骑手收入投影;金额已含小费,代收款仅作说明,不参与收入求和。 */
+@Data
+public class RiderIncomeItem {
+    private Long billId;
+    private String orderSource;
+    private String orderNo;
+    private BigDecimal incomeAmount;
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date incomeTime;
+    private BigDecimal collectedAmount;
+}

+ 14 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/RiderIncomeSummary.java

@@ -0,0 +1,14 @@
+package com.ruoyi.system.domain.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/** 所选周期的全部骑手订单收入,不受明细分类或分页影响。 */
+@Data
+public class RiderIncomeSummary {
+    private BigDecimal totalIncome;
+    private long totalOrderCount;
+    private long foodOrderCount;
+    private long flashOrderCount;
+}

+ 19 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/RiderIncomeMapper.java

@@ -0,0 +1,19 @@
+package com.ruoyi.system.mapper;
+
+import com.ruoyi.system.domain.vo.RiderIncomeItem;
+import com.ruoyi.system.domain.vo.RiderIncomeSummary;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+public interface RiderIncomeMapper {
+    RiderIncomeSummary summarize(@Param("riderId") Long riderId,
+                                 @Param("startTime") String startTime, @Param("endTime") String endTime);
+
+    List<RiderIncomeItem> selectPage(@Param("riderId") Long riderId,
+                                    @Param("startTime") String startTime, @Param("endTime") String endTime,
+                                    @Param("orderSource") String orderSource,
+                                    @Param("offset") long offset, @Param("size") int size);
+
+    RiderIncomeItem selectDetail(@Param("riderId") Long riderId, @Param("billId") Long billId);
+}

+ 44 - 0
ruoyi-system/src/main/resources/mapper/Billing/RiderIncomeMapper.xml

@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.RiderIncomeMapper">
+    <sql id="incomeScope">
+        b.user_id = #{riderId} AND b.user_type = '2' AND b.type = '0' AND b.state = '0'
+        AND b.dd_id IS NOT NULL AND TRIM(b.dd_id) != ''
+        AND (b.order_source IS NULL OR b.order_source = '' OR b.order_source IN ('FOOD', 'FLASH'))
+    </sql>
+    <sql id="timeRangeScope">
+        <if test="startTime != null and endTime != null">
+            AND b.cretim &gt;= #{startTime} AND b.cretim &lt;= #{endTime}
+        </if>
+    </sql>
+    <sql id="normalizedSource">
+        COALESCE(NULLIF(b.order_source, ''), 'FOOD')
+    </sql>
+    <sql id="incomeAmount">
+        CAST(COALESCE(b.amount, 0) AS DECIMAL(20, 2))
+    </sql>
+    <sql id="incomeFields">
+        b.id AS billId, <include refid="normalizedSource"/> AS orderSource, b.dd_id AS orderNo,
+        <include refid="incomeAmount"/> AS incomeAmount, b.cretim AS incomeTime,
+        CAST(COALESCE(b.behalf_amount, 0) AS DECIMAL(20, 2)) AS collectedAmount
+    </sql>
+    <select id="summarize" resultType="com.ruoyi.system.domain.vo.RiderIncomeSummary">
+        SELECT CAST(COALESCE(SUM(<include refid="incomeAmount"/>), 0) AS DECIMAL(20, 2)) AS totalIncome,
+               COUNT(*) AS totalOrderCount,
+               COALESCE(SUM(CASE WHEN <include refid="normalizedSource"/> = 'FOOD' THEN 1 ELSE 0 END), 0) AS foodOrderCount,
+               COALESCE(SUM(CASE WHEN b.order_source = 'FLASH' THEN 1 ELSE 0 END), 0) AS flashOrderCount
+        FROM user_billing b WHERE <include refid="incomeScope"/> <include refid="timeRangeScope"/>
+    </select>
+    <select id="selectPage" resultType="com.ruoyi.system.domain.vo.RiderIncomeItem">
+        SELECT <include refid="incomeFields"/>
+        FROM user_billing b WHERE <include refid="incomeScope"/> <include refid="timeRangeScope"/>
+        <if test="orderSource != 'ALL'">
+            AND <include refid="normalizedSource"/> = #{orderSource}
+        </if>
+        ORDER BY b.cretim DESC, b.id DESC LIMIT #{offset}, #{size}
+    </select>
+    <select id="selectDetail" resultType="com.ruoyi.system.domain.vo.RiderIncomeItem">
+        SELECT <include refid="incomeFields"/>
+        FROM user_billing b WHERE <include refid="incomeScope"/> AND b.id = #{billId}
+    </select>
+</mapper>

+ 14 - 0
specs/003-merchant-billing/plan.md

@@ -231,3 +231,17 @@ git commit -m "feat(billing): 商家端账单功能 — 列表查询、汇总、
 ### 旧账单范围补齐(2026-11-04)
 
 分管账号新增独立日/周/月查询分支;UserBillingMapper XML 共用主账号和门店集合条件,日期绑定参数、空集合显式 AND 1=0。保留旧响应结构和其他角色路径。验证包括授权多店/单店、越权、空授权、撤权、取消范围和 XML BoundSql。
+
+## 2026-11-05 骑手收入账单实施计划(按最终范围更新)
+
+1. `ruoyi-system` 的 RiderIncomeMapper、XML、RiderIncomeSummary、RiderIncomeItem 共用本人有效订单收入条件。按明确起止时间过滤;汇总不接收分类,明细按分类分页,详情按本人和 billId 查询。
+2. `ruoyi-admin` 的 RiderIncomeController 显式接收 token、startTime、endTime、orderSource、pageNum、pageSize;RiderIncomeService 校验角色、成对有效日期、起止顺序和分页,严格校验完整时分秒(秒精度)及时间顺序,直接绑定原值按闭区间查询。移除 period、periodOptions、Clock 及周期选项 DTO/测试。
+3. RiderIncomePage 仅保留范围、全量汇总、分类条数、页码和明细,不生成前端选择器内容。客户端负责天/周/月选项与默认选中,点击时提交真实日期范围。
+4. H2 实际 SQL、Service 和 Controller 回归覆盖任意范围、跨年/闰年、边界、无范围、非法/单边范围、权限、金额及汇总稳定性;JDK21 package 和差异检查。
+5. 更新 rider-income-api.md、规格与任务,国际化查询错误去掉周期措辞。无数据库迁移,不创建分支或自动提交。
+
+### 订单号直接查询(2026-11-05)
+
+1. RiderIncomeItem 移除 orderId,XML 移除订单主表子查询,直接以 orderNo 返回账单 dd_id。
+2. 接入文档明确 FOOD 使用 getOrderDetial 的 ddId 参数,FLASH 使用 orders/byOrderNo 的 orderNo 参数;沿用详情接口鉴权。
+3. 仅创建账单表运行 SQL 测试,覆盖来源区分、历史记录、分页与权限;验证响应不含 orderId,执行骑手账单定向测试及 JDK21 打包。

+ 216 - 0
specs/003-merchant-billing/rider-income-api.md

@@ -0,0 +1,216 @@
+# 骑手收入账单 App 接入说明
+
+更新:2026-11-05。适用:骑手 App 收入账单页面。前端计算日期选项和默认选中项,后端只接收完整起止时间。
+
+页面展示收入合计、收入订单数、外卖订单数、闪送订单数,下方展示可按类型筛选的收入明细。金额已直接给骑手,不展示钱包余额、提现、待结算或已收款状态;小费已包含在收入中,不单独拆分。
+
+## 查询汇总与分页明细
+
+`GET /Billing/billing/riderIncome`
+
+请求头:`token`,仅启用、未删除的骑手可查看本人收入;不接收骑手 ID。
+
+| 参数 | 默认 | 说明 |
+|---|---|---|
+| startTime | 不传 | 开始时间 yyyy-MM-dd HH:mm:ss,包含该时刻 |
+| endTime | 不传 | 结束时间 yyyy-MM-dd HH:mm:ss,包含该时刻 |
+| orderSource | ALL | ALL / FOOD / FLASH,仅筛选下方列表 |
+| pageNum | 1 | 正整数 |
+| pageSize | 20 | 1—100 |
+
+两个时间同时不传表示全部历史;筛选时必须两个都传。拒绝单边、空字符串、无效日期/时分秒和起点晚于终点。支持任意范围,不要求自然周/月。时间按 UTC+8 解释,精度为秒,不接受仅日期、缺少秒、毫秒或时区后缀。查询包含开始和结束时刻。年份范围为1000—9999。
+
+```text
+今天:startTime=2026-11-05 00:00:00&endTime=2026-11-05 23:59:59
+本周:startTime=2026-11-02 00:00:00&endTime=2026-11-08 23:59:59
+本月:startTime=2026-11-01 00:00:00&endTime=2026-11-30 23:59:59
+任意时段:startTime=2026-11-05 12:30:15&endTime=2026-11-05 18:45:30
+全部历史:不传 startTime 和 endTime
+```
+
+GET 请求使用客户端请求库编码参数,空格需 URL 编码为 `%20`,不要把未编码的空格直接拼进 URL。
+
+## 响应示例
+
+请求:`/Billing/billing/riderIncome?startTime=2026-11-05%2000:00:00&endTime=2026-11-05%2023:59:59&orderSource=FLASH&pageNum=1&pageSize=20`
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {
+    "startTime": "2026-11-05 00:00:00",
+    "endTime": "2026-11-05 23:59:59",
+    "summary": {
+      "totalIncome": 1250.00,
+      "totalOrderCount": 18,
+      "foodOrderCount": 12,
+      "flashOrderCount": 6
+    },
+    "total": 6,
+    "pageNum": 1,
+    "pageSize": 20,
+    "list": [
+      {
+        "billId": 9001,
+        "orderSource": "FLASH",
+        "orderNo": "FD202611050001",
+        "incomeAmount": 120.00,
+        "incomeTime": "2026-11-05 12:20:00",
+        "collectedAmount": 0.00
+      }
+    ]
+  }
+}
+```
+
+示例 list 仅展示一条。顶部使用 summary 的四项:收入合计、收入订单、外卖订单、闪送订单。总单数等于两类之和。summary 固定为日期范围内两类订单的整体统计,切换分类或页码不变。
+
+`total` 是当前分类的明细总数,不能用于顶部总单数。列表按 incomeTime 倒序,同一时间按 billId 倒序。无收入时 summary 四项为0、total=0、list=[];当前分类为空或翻到最后一页以外时 list=[],summary 仍为全部分类汇总。零元收入记录也计单。全部历史时 startTime/endTime 为 null。
+
+## 响应字段与页面绑定
+
+响应使用 `code/msg/data` 包装,业务成功判断为 `code === 200`;请求封装如果已经解包,应按项目实际封装取 data。
+
+| data 字段 | 类型 | 前端用途 |
+|---|---|---|
+| startTime / endTime | string 或 null | 本次查询范围,全部历史时为 null |
+| summary.totalIncome | number | 顶部收入合计,TWD,显示两位小数 |
+| summary.totalOrderCount | number | 顶部收入订单数 |
+| summary.foodOrderCount | number | 顶部外卖订单数 |
+| summary.flashOrderCount | number | 顶部闪送订单数 |
+| total | number | 当前分类总条数,用于分页和判断加载结束 |
+| pageNum / pageSize | number | 本次页码和每页条数 |
+| list | array | 当前页账单列表 |
+
+| list 单条 / 详情 data 字段 | 类型 | 前端用途 |
+|---|---|---|
+| billId | number | 账单 ID,列表 key、查询账单详情使用 |
+| orderSource | string | FOOD=外卖,FLASH=闪送 |
+| orderNo | string | 订单号,用于展示和查询原订单;不要转换为数字 |
+| incomeAmount | number | 本条收入,TWD,显示两位小数,已包含小费 |
+| incomeTime | string | 收入记录时间,yyyy-MM-dd HH:mm:ss,UTC+8 |
+| collectedAmount | number | 代收款,TWD,单独展示,不计入收入 |
+
+顶部金额直接使用 summary.totalIncome,不在前端累加当前页金额作为总收入。显示时格式化小数,不以 JSON 文本是否保留末尾 0 判断金额精度。
+
+## 前端日期选择交互
+
+| 模式 | 前端生成的选项(由旧到新) | 默认选中 | 向后端传值 |
+|---|---|---|---|
+| 天 | 本周一至周日七天 | 今天 | 所选日00:00:00至23:59:59 |
+| 周 | 含本周在内的最近五周 | 本周 | 所选周周一00:00:00至周日23:59:59 |
+| 月 | 含本月在内的最近五个月 | 本月 | 所选月一号00:00:00至末日23:59:59 |
+
+日期计算、默认选中、高亮、“今天”及星期文案全部由前端完成,业务时区统一 UTC+8。切换主标签时按新模式选中今天/本周/本月,重新计算范围,不沿用上一模式的范围。点击日期选项或切换分类都重置 pageNum=1;仅切换分类时日期范围保持不变。
+
+例如业务今天为 2026-11-05:天选项为11-02至11-08;周起点为10-05、10-12、10-19、10-26、11-02;月选项为2026-07至2026-11。选择历史选项不移动固定窗口。按自然日计算跨年周、月份天数和闰年;本周未来日期仍显示,可查询且无收入时为空。跨午夜刷新前端日期窗口。全部时间入口同时省略两个时间参数。
+
+## 单笔详情与原订单
+
+`GET /Billing/billing/riderIncome/{billId}`,同样必须携带 token。
+
+data 为上述单条 list 对象。billId 是收入账单主键,不是原订单主键。展示本单收入、类型、完整订单号、收入记录时间;有代收款时单独标注“不计入收入”。
+
+使用 orderSource 区分外卖和闪送详情页面,使用 orderNo 查询原订单。两个接口均需携带 token 请求头:
+
+| orderSource | 原订单详情接口(GET) | 参数映射 |
+|---|---|---|
+| FOOD | /system/orderQsOprate/getOrderDetial | ddId = 账单的 orderNo |
+| FLASH | /system/flashDelivery/rider/orders/byOrderNo | orderNo = 账单的 orderNo |
+
+例如闪送账单的 orderNo 为 FD202611050001,调用 `/system/flashDelivery/rider/orders/byOrderNo?orderNo=FD202611050001`。原订单不存在或不可访问时,展示详情接口的错误提示并保留账单页面;不要把查询失败当作账单失效。
+
+详情请求示例:`GET /Billing/billing/riderIncome/9001`。
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {
+    "billId": 9001,
+    "orderSource": "FLASH",
+    "orderNo": "FD202611050001",
+    "incomeAmount": 120.00,
+    "incomeTime": "2026-11-05 12:20:00",
+    "collectedAmount": 0.00
+  }
+}
+```
+
+## uni-app 请求示例
+
+以下直接使用 uni.request,baseUrl 和 token 由项目现有环境配置及登录模块提供。请求库负责 GET 参数编码,不要提前 encodeURIComponent 后再交给请求库重复编码。
+
+```javascript
+function requestIncome(baseUrl, token, path, params = {}) {
+  return new Promise((resolve, reject) => {
+    uni.request({
+      url: `${baseUrl}/Billing/billing/riderIncome${path}`,
+      method: 'GET',
+      header: { token },
+      data: params,
+      success(res) {
+        const body = res.data;
+        if (res.statusCode >= 200 && res.statusCode < 300 && body?.code === 200) {
+          resolve(body.data);
+        } else {
+          // 交由项目统一逻辑处理登录失效及错误提示,不将失败当作空账单。
+          reject({ statusCode: res.statusCode, response: body });
+        }
+      },
+      fail: reject
+    });
+  });
+}
+
+// 所选日:前端根据业务时区 UTC+8 生成完整时间。
+const page = await requestIncome(baseUrl, token, '', {
+  startTime: '2026-11-05 00:00:00',
+  endTime: '2026-11-05 23:59:59',
+  orderSource: 'ALL',
+  pageNum: 1,
+  pageSize: 20
+});
+
+// 全部历史:省略两个时间字段,不传空字符串或字符串 "null"。
+const allHistory = await requestIncome(baseUrl, token, '', {
+  orderSource: 'ALL', pageNum: 1, pageSize: 20
+});
+
+const detail = await requestIncome(baseUrl, token, '/9001');
+```
+
+不要直接使用设备时区计算业务日期,也不要用 toISOString() 生成入参(会转换为 UTC 并携带 T/Z);先按 UTC+8 算好年月日,再输出约定的时间字符串。
+
+## 分页、刷新与空态
+
+- 首次进入页面使用前端当前模式的默认日期和 pageNum=1 请求。
+- 切换天/周/月、日期选项或订单分类时清空旧列表,重置页码为1并重新查询。顶部绑定新响应的 summary,不用 total 替换总单数。
+- 下拉刷新从第1页重新加载;加载下一页时保持时间范围、分类、pageSize 不变,成功后再追加列表和更新页码,失败可重试原页。
+- 已加载条数达到 total,或新页 list 为空时停止加载。查询期间可能新增收入,下拉刷新可重新获取最新分页结果。
+- 快速切换条件时忽略旧条件的迟到响应,避免旧列表覆盖当前选择。
+- summary.totalOrderCount=0 时展示所选范围暂无收入;只有当前分类 total=0 时仅展示该分类为空,保留顶部整体统计。请求失败显示错误与重试,不显示为零收入。
+
+## 金额展示规则
+
+- incomeTime 表示收入记录时间,页面标注为“收入时间”,不作为实际到账时间展示。
+- incomeAmount 为本条骑手收入,单位 TWD,已包含小费。直接使用接口金额并显示两位小数,无需重新计算。
+- collectedAmount 是已记录的代收款,缺省0,不属于收入、不加入totalIncome。
+- 响应无小费分项、钱包余额、提现、待结算或已收款标签。
+
+## 错误与权限
+
+请求失败时使用项目统一错误提示,展示响应中的 msg;msg 会随语言变化,不要按固定文本判断业务状态。时间范围、分类或分页非法时提示用户修正查询条件;账号无访问权限或详情不可用时展示错误,不将其当作空账单。
+
+token 缺失或无效时按项目现有认证流程处理。接口仅支持查询当前登录骑手的账单。
+
+## 前端联调检查项
+
+- 天展示本周七天并默认今天;周展示最近五周并默认本周;月展示最近五个月并默认本月。
+- 切换范围后请求携带 00:00:00 到末日 23:59:59;月底、闰年二月、跨年周计算正确。
+- 仅切换 FOOD/FLASH 时日期保持不变、页码回到1;顶部统计口径仍为全部分类。
+- 总单数等于外卖单数加闪送单数;代收款不累加进收入,不重复增加小费。
+- 分页追加、刷新替换、空分类、无收入、网络失败和快速切换均按上述规则处理。
+- 账单详情使用 billId;原订单详情按 orderSource 选择接口,将 orderNo 映射为外卖 ddId 或闪送 orderNo;原订单不可访问时保留账单。
+- 全部历史查询省略两个时间字段;登录失效使用现有登录流程。

+ 17 - 0
specs/003-merchant-billing/spec.md

@@ -171,3 +171,20 @@ mdId 过滤采用 `(#{mdId} IS NULL OR md_id = #{mdId})` 模式,mdId 不传时
 ### 2026-11-04 旧日/周/月账单分管兼容
 
 `getzddaylist/getzdmeeklist/getzdmatlist` 对 userType=5 解析主账号及实时授权门店;不传 mdId 查询全部授权门店,指定未授权门店拒绝。明细、金额/商品汇总、取消订单列表及数量使用相同范围,空授权查询零行。原普通商家/夜市/摊主/骑手路径不变,无数据库迁移。
+
+## 2026-11-05 骑手收入账单改版(最终范围:前端日期,后端范围查询)
+
+### 用户故事与验收
+- 前端计算日期切换条:天展示本周一至周日七天默认今天;周展示含本周的最近五周默认本周;月展示含本月的最近五个月默认本月。选项由旧到新排列,日期与星期文案由前端国际化。
+- 后端仅接收 startTime/endTime(yyyy-MM-dd HH:mm:ss,起止时刻都包含),不接收 period/date,不计算周期、默认日期、最近几周/几月,不返回 periodOptions。两个日期均不传查询全部历史;仅传一个、无效日期或起点晚于终点拒绝。
+- 顶部固定显示范围内收入合计、收入订单数、外卖订单数、闪送订单数。订单类型仅筛选下方分页明细;汇总不受分类或页码影响。
+- 小费已包含在收入内,不另列小费;无钱包余额、提现、待结算或已收款标签。代收款独立返回,不计收入。
+- 按有效收入账单产生时间统计,读取历史保存金额,不重算当前价格、不重复扣历史抽成/税、不因取消状态擅自调整历史账单。
+- 仅启用且未删除的骑手查看本人记录。详情不存在、越权、非收入使用统一错误。账单直接返回保存的订单号 orderNo(来源 dd_id),不查询订单表或返回订单主键。点击原订单时按 orderSource 调用既有骑手详情接口;原订单不可访问不影响历史收入。
+
+### 技术边界
+- 新接口 `/Billing/billing/riderIncome` 与 `/Billing/billing/riderIncome/{billId}`,旧共用账单接口保持不变。
+- 本人 user_type=2,type=0,state=0 且有订单号的账单;null/空来源作为 FOOD,FLASH 明确区分,其他来源不纳入。
+- 按 UTC+8 业务时间解释范围,数据库使用 cretim >= startTime AND cretim <= endTime,原样绑定传入的完整时分秒,不补零点或增加一天;支持任意日期范围与跨年,不局限自然周/月。
+- 逐笔金额按保存的两位精度返回与求和;零元记录计单。保留入账防重逻辑,不擅自合并历史记录。
+- 本次只做后端与接入文档;不改 App、真实支付、钱包写入、账单生成或历史数据库,不创建分支,不自动提交推送。

+ 44 - 0
specs/003-merchant-billing/tasks.md

@@ -140,3 +140,47 @@ description: "Task list for 商家前端账单功能 feature implementation"
 - [x] T021 补齐分管旧日/周/月账单及取消统计的实时授权范围,执行控制器与 SQL 回归,保留骑手原账单行为。验证结果与022 T015共用。
 
 T021验证:022 T015所列JDK21定向回归144项全部通过,模块package成功,差异检查通过。分管旧账单控制器12项、SQL范围测试1项,以及既有商家/骑手账单回归均包含在内。无数据库变更,未部署;提交与推送状态以 Git 记录为准。
+
+## 2026-11-05 骑手收入账单改版
+
+- [x] T028 写入已确认规格与实现计划,确认仅后端和接入文档,不创建分支。
+- [x] T029 新增 Mapper、金额投影与 SQL 实际执行测试,统一范围、日期和分类口径。
+- [x] T030 新增骑手专用接口与服务、参数国际化校验、固定汇总和受权限保护的单笔详情。
+- [x] T031 补充 App 接入文档及示例,验证不返回钱包/提现/小费分项。
+- [x] T032 运行定向回归、JDK21 打包和差异/换行检查,记录结果与未执行项目。
+
+- [x] T033 补充全部时间查询,默认今日,验证全历史收入/分页/日期互斥并同步契约。
+
+验证记录(2026-11-05):spec-kit `check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` 通过,使用现有 003 规格增量。本次直接在原分支修改,临时源码副本隔离 Maven 产物;未创建分支。首次 4 项 SQL 测试因 Mapper 缺失失败;全部时间扩展另有 2 项红灯,分别复现日期条件导致累计收入为 0、Service 拒绝 ALL。最终 JDK 21.0.12 执行:
+
+`mvn -q -pl ruoyi-admin -am -Dtest=RiderIncomeMapperTest,RiderIncomeServiceTest,RiderIncomeControllerTest,UserBillingFlashDeliveryTest,OrderBillingNoDeductionTest,AuthAspectTest -Dsurefire.failIfNoSpecifiedTests=false package`
+
+37 项全部通过(SQL 5、Service 10、Controller 3、旧闪送账单 9、无抽成结算 5、认证 5),失败/错误/跳过均 0,Maven package 退出码 0。SQL 在 H2 MySQL 兼容模式实际执行。校验临时副本与交付源码相同,六份国际化文件的新 key 齐全且唯一,差异检查通过;独立只读评审未发现重要问题。
+
+日志:`ruoyi-admin/target/rider-income-red.log`、`rider-income-all-red.log`、`rider-income-green.log`。未执行生产 MySQL 查询/性能验证、真实账号与 App 联调、全量回归或部署;没有数据库迁移/历史回写,没有提交或推送,因此无本次提交 SHA。当前交付为后端及 `rider-income-api.md`,App 由调用方接入。
+
+- [x] T034 按截图增加本周七天、最近五周、最近五月日期选项和默认选中信息,补齐跨年/闰年测试及 App 契约并验证。
+
+T034 验证(2026-11-05):旧响应缺少 periodOptions 的 5 项测试按预期失败;补齐实现及固定 Clock 边界用例后,JDK21 执行 `mvn -q -pl ruoyi-admin -am -Dtest=RiderIncomePeriodOptionsTest,RiderIncomeMapperTest,RiderIncomeServiceTest,RiderIncomeControllerTest,UserBillingFlashDeliveryTest,OrderBillingNoDeductionTest,AuthAspectTest -Dsurefire.failIfNoSpecifiedTests=false package`,45 项全部通过(日期选项 8 项),失败/错误/跳过均 0,package 退出码 0。独立只读复审未发现重要问题,API 示例 JSON 及日期选中项检查通过,验证源码与工作区一致,git diff --check 通过。
+
+日志:`ruoyi-admin/target/rider-income-period-options-red.log`、`rider-income-period-options-green.log`。本次仅后端响应及 spec-kit/接入文档;没有创建分支、提交、推送、数据库迁移或部署。未做 App 实机联调、生产 MySQL 与全量回归;无本次提交 SHA。
+
+## 最终职责调整:前端计算日期、后端只查范围(2026-11-05)
+
+T033/T034 中 period=ALL 与后端日期选项实现已被本节替代;前期测试记录仅作过程证据,不作为最终契约。
+- [x] T035 将接口改为 startTime/endTime 成对完整时间查询(yyyy-MM-dd HH:mm:ss,闭区间),无范围查询全部历史;移除后端周期选择计算与 DTO,不再补零点或换算结束日期。
+- [x] T036 更新范围/Controller 回归、六份国际化文案和 App 接入说明,运行定向回归与模块打包,记录最终验证。
+
+用户最终补充:前端发送日期开始/结束的完整时分秒,例如 2026-11-05 00:00:00 至 2026-11-05 23:59:59;仅日期的中间实现已撤回。时间闭区间实际SQL回归覆盖末秒与起止时刻相等场景。
+
+T035/T036 最终验证(2026-11-05):仅日期的中间实现已撤回,最终采用 startTime/endTime 秒精度闭区间。SQL 红灯 6 项中 3 项按预期失败,复现结束时刻账单遗漏及相同时刻查询为空;修复后 JDK21 执行 `mvn -q -pl ruoyi-admin -am -Dtest=RiderIncomeMapperTest,RiderIncomeServiceTest,RiderIncomeControllerTest,UserBillingFlashDeliveryTest,OrderBillingNoDeductionTest,AuthAspectTest -Dsurefire.failIfNoSpecifiedTests=false package`,38 项全部通过(SQL 6、Service 10、Controller 3、旧闪送账单 9、无抽成结算 5、认证 5),失败/错误/跳过均0,package退出码0。
+
+独立复审指出的两处旧 Controller 验证参数已修正,最终复审无重要问题。源码与临时验证副本一致,API示例JSON及差异检查通过。日志:`ruoyi-admin/target/rider-income-time-range-red.log`、`rider-income-time-range-green.log`。未执行生产MySQL/性能、App实机联调、全量回归或部署;未创建分支、提交、推送或数据库迁移,无本次提交SHA。
+
+### 直接使用账单订单号(2026-11-05)
+
+- [x] T037 去掉骑手账单查询的订单主表关联与 orderId,使用 orderSource + orderNo 跳转既有骑手详情,更新接入文档并验证仅依赖账单表的查询和响应契约。
+
+T037 验证(2026-11-05):响应契约测试先出现预期失败(仍含 orderId,7 字段而非6字段);删除投影字段及订单主表子查询后,JDK21 执行 `mvn -q -pl ruoyi-admin -am -Dtest=RiderIncomeMapperTest,RiderIncomeServiceTest,RiderIncomeControllerTest -Dsurefire.failIfNoSpecifiedTests=false package`,19项通过(SQL6、Service10、Controller3),失败/错误/跳过均0,package退出码0。SQL用例仅建立 user_billing 表,验证账单读取无需订单主表;JSON示例与工作区测试源码核对通过,git diff --check通过。未进行真实MySQL性能测试、App联调、完整回归或部署;未创建分支、提交或推送。
+
+提交前验证(2026-11-05):按用户 commit push 指令,仅暂存骑手账单代码、测试、六份语言文件的骑手提示和003规格增量,共20个文件;整数运费、旧接口删除等其他工作区改动不纳入。从暂存区导出独立源码副本,JDK21执行T035/T036所列6类定向测试及package,38项全部通过,失败/错误/跳过均0,打包退出码0。暂存差异检查通过。未执行App联调、真实MySQL性能测试、完整回归或部署;实际提交及推送结果以Git记录为准。