3 커밋 55dcdacec5 ... cf68dbf411

작성자 SHA1 메시지 날짜
  alphaH cf68dbf411 feat: 转账回单接口切换为ISV协议模式(alipay.data.bill.ereceiptagent) + 修复轮询undefined 2 주 전
  alphaH 1c48a7015c docs: 转账记录导出Excel实现计划 2 주 전
  alphaH 6241c459db docs: 转账记录导出Excel+时间筛选+导出修复 设计文档 2 주 전

+ 551 - 0
.claude/plan/2026-08-11-transfer-export.md

@@ -0,0 +1,551 @@
+# 转账记录导出 Excel + 时间筛选 + 导出修复 — 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 转账管理页面(`module_payment/account/index.vue` "转账记录" tab)导出 Excel 跟随表单筛选条件,新增时间范围筛选,并修复导出时间格式(东8)与收款方类型翻译。
+
+**Architecture:** 改造后端现有 `GET /transfer/export`(参数改为与 `transferList` 一致的 6 个可选筛选条件、导出列 11 列、创建时间格式化东8、类型 map 补齐),`transferList` 同步支持时间筛选;前端恢复"下载报表"按钮(blob 下载)、筛选表单加时间选择器并加宽控件、清理旧导出死代码。
+
+**Tech Stack:** Java 17 / Spring Boot / MyBatis-Plus / Lombok;Vue3 + Vite + TypeScript + Element Plus + dayjs + ExcelJS。
+
+## Global Constraints
+
+- 设计文档(已批准):`docs/superpowers/specs/2026-08-11-transfer-export-design.md`
+- 时间解析沿用 `parseDateTime`(结束日期解析为当天 00:00:00,与消费记录一致)
+- 条件语义与 `transferList` 逐条一致(`eq` 精确匹配)
+- 权限保持 `module_payment:account:transfer:list`(按钮 `v-hasPerm` 同权限码)
+- 导出列 11 列:序号、订单号、商户订单号、金额(元)、收款方姓名、收款方类型、状态、转账标题、创建时间、备注、失败原因
+- 创建时间统一东8 `yyyy-MM-dd HH:mm:ss`(后端 `atZoneSameInstant(Asia/Shanghai)`,前端 dayjs)
+- 空数据导出空 sheet,不特判
+- 项目无后端测试框架(`java/src/test` 不存在)、无前端单测(无 vitest/jest);每任务验证 = 编译/类型检查 + 逻辑对照 + 最终 Playwright 端到端
+- 后端项目路径 `../java`(相对工作目录 frontend/),Maven 编译:`cd ../java && mvn -q compile -DskipTests`
+
+---
+
+### Task 1: 后端 transferList 支持时间范围筛选
+
+**Files:**
+- Modify: `../java/src/main/java/com/payment/platform/module/payment/account/service/AccountService.java:190-201`
+- Modify: `../java/src/main/java/com/payment/platform/module/payment/account/controller/AccountController.java:76-84`
+
+**Interfaces:**
+- Produces: `transferList(int pageNo, int pageSize, String outBizNo, String status, Long tenantId, String enterpriseId, String startTime, String endTime)` — 新参数 `startTime`/`endTime`(`yyyy-MM-dd` 或 `yyyy-MM-dd HH:mm:ss`,可空)
+
+- [ ] **Step 1: service 加时间过滤参数**
+
+`AccountService.transferList` 签名与查询条件改为:
+
+```java
+public PageResult<TransferVO> transferList(int pageNo, int pageSize, String outBizNo, String status,
+        Long tenantId, String enterpriseId, String startTime, String endTime) {
+    var w = new LambdaQueryWrapper<TransferEntity>().orderByDesc(TransferEntity::getId);
+    if (outBizNo != null && !outBizNo.isBlank()) w.eq(TransferEntity::getOutBizNo, outBizNo);
+    if (status != null && !status.isBlank()) w.eq(TransferEntity::getStatus, status);
+    if (tenantId != null) w.eq(TransferEntity::getTenantId, tenantId);
+    if (enterpriseId != null && !enterpriseId.isBlank()) w.eq(TransferEntity::getEnterpriseId, enterpriseId);
+    if (startTime != null && !startTime.isBlank()) w.ge(TransferEntity::getCreatedTime, parseDateTime(startTime));
+    if (endTime != null && !endTime.isBlank()) w.le(TransferEntity::getCreatedTime, parseDateTime(endTime));
+    var p = new Page<TransferEntity>(pageNo, pageSize);
+    var r = transferMapper.selectPage(p, w);
+    List<TransferVO> items = r.getRecords().stream().map(this::transferVO).toList();
+    return PageResult.of(pageNo, pageSize, r.getTotal(), items);
+}
+```
+
+- [ ] **Step 2: controller 透传新参数**
+
+`AccountController.transferList` 改为:
+
+```java
+@PreAuthorize("@perm.hasAny('module_payment:account:transfer:list')")
+@GetMapping("/transfer")
+public Result<PageResult<TransferVO>> transferList(
+        @RequestParam(name = "page_no", defaultValue = "1") int pageNo, @RequestParam(name = "page_size", defaultValue = "20") int pageSize,
+        @RequestParam(name = "out_biz_no", required = false) String outBizNo, @RequestParam(required = false) String status,
+        @RequestParam(name = "tenant_id", required = false) Long tenantId,
+        @RequestParam(name = "enterprise_id", required = false) String enterpriseId,
+        @RequestParam(name = "start_time", required = false) String startTime,
+        @RequestParam(name = "end_time", required = false) String endTime) {
+    return Result.ok(accountService.transferList(pageNo, pageSize, outBizNo, status, tenantId, enterpriseId, startTime, endTime));
+}
+```
+
+- [ ] **Step 3: 编译验证**
+
+Run: `cd ../java && mvn -q compile -DskipTests`
+Expected: BUILD SUCCESS(无编译错误)
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd ../java && git add src/main/java/com/payment/platform/module/payment/account/service/AccountService.java src/main/java/com/payment/platform/module/payment/account/controller/AccountController.java && git commit -m "feat: 转账列表支持时间范围筛选"
+```
+
+---
+
+### Task 2: 后端 transferExport 改造(6 条件 + 11 列 + 东8时间 + 类型翻译)
+
+**Files:**
+- Modify: `../java/src/main/java/com/payment/platform/module/payment/account/controller/AccountController.java:237-249`
+- Modify: `../java/src/main/java/com/payment/platform/module/payment/account/service/AccountService.java:295-369`
+
+**Interfaces:**
+- Consumes: Task 1 的 `parseDateTime`(已有)与筛选条件语义
+- Produces: `transferExport(String outBizNo, String status, Long tenantId, String enterpriseId, String startTime, String endTime)` 返回 xlsx 字节数组;下载文件名 `transfer_report.xlsx`
+
+- [ ] **Step 1: controller 参数改为 6 条件**
+
+`AccountController.transferExport` 整体替换为:
+
+```java
+@PreAuthorize("@perm.hasAny('module_payment:account:transfer:list')")
+@GetMapping("/transfer/export")
+public void transferExport(
+        @RequestParam(name = "out_biz_no", required = false) String outBizNo,
+        @RequestParam(name = "status", required = false) String status,
+        @RequestParam(name = "tenant_id", required = false) Long tenantId,
+        @RequestParam(name = "enterprise_id", required = false) String enterpriseId,
+        @RequestParam(name = "start_time", required = false) String startTime,
+        @RequestParam(name = "end_time", required = false) String endTime,
+        HttpServletResponse response) throws IOException {
+    byte[] bytes = accountService.transferExport(outBizNo, status, tenantId, enterpriseId, startTime, endTime);
+    response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+    response.setHeader("Content-Disposition",
+            "attachment; filename=transfer_report.xlsx");
+    response.getOutputStream().write(bytes);
+}
+```
+
+- [ ] **Step 2: service 方法整体改写**
+
+`AccountService.transferExport` 方法体替换为(注意保留类中已存在的 `ObjectMapper om`、`ExcelUtil.exportToExcel` 用法):
+
+```java
+public byte[] transferExport(String outBizNo, String status, Long tenantId, String enterpriseId,
+        String startTime, String endTime) {
+    try {
+        LambdaQueryWrapper<TransferEntity> w = new LambdaQueryWrapper<>();
+        if (outBizNo != null && !outBizNo.isBlank()) w.eq(TransferEntity::getOutBizNo, outBizNo);
+        if (status != null && !status.isBlank()) w.eq(TransferEntity::getStatus, status);
+        if (tenantId != null) w.eq(TransferEntity::getTenantId, tenantId);
+        if (enterpriseId != null && !enterpriseId.isBlank()) w.eq(TransferEntity::getEnterpriseId, enterpriseId);
+        if (startTime != null && !startTime.isBlank()) w.ge(TransferEntity::getCreatedTime, parseDateTime(startTime));
+        if (endTime != null && !endTime.isBlank()) w.le(TransferEntity::getCreatedTime, parseDateTime(endTime));
+        w.orderByDesc(TransferEntity::getId);
+
+        List<TransferEntity> records = transferMapper.selectList(w);
+
+        Map<String, String> statusMap = Map.of(
+                "DEALING", "处理中",
+                "SUCCESS", "成功",
+                "FAIL", "失败",
+                "REFUND", "退票");
+
+        Map<String, String> payeeTypeMap = Map.of(
+                "ALIPAY_USER_ID", "支付宝账户",
+                "ALIPAY_LOGON_ID", "支付宝账户",
+                "ALIPAY_ACCOUNT", "支付宝账户",
+                "BANKCARD_ACCOUNT", "银行卡",
+                "BANK_CARD", "银行卡");
+
+        ObjectMapper om = new ObjectMapper();
+        List<Map<String, Object>> listData = new ArrayList<>();
+        DateTimeFormatter timeFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+        int seq = 1;
+        for (TransferEntity r : records) {
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("序号", seq++);
+            row.put("订单号", r.getOutBizNo() != null ? r.getOutBizNo() : "");
+            row.put("商户订单号", r.getOrderNo() != null ? r.getOrderNo() : "");
+            row.put("金额(元)", r.getAmount() != null ? r.getAmount().toPlainString() : "0");
+            row.put("状态", statusMap.getOrDefault(r.getStatus(), r.getStatus() != null ? r.getStatus() : ""));
+            row.put("转账标题", r.getOrderTitle() != null ? r.getOrderTitle() : "");
+            row.put("创建时间", r.getCreatedTime() != null
+                    ? r.getCreatedTime().atZoneSameInstant(ZoneId.of("Asia/Shanghai")).format(timeFmt) : "");
+
+            // 解析 payeeInfo JSON
+            String name = "";
+            String identityType = "";
+            if (r.getPayeeInfo() != null && !r.getPayeeInfo().isBlank()) {
+                try {
+                    @SuppressWarnings("unchecked")
+                    Map<String, Object> payeeInfo = om.readValue(r.getPayeeInfo(), Map.class);
+                    name = String.valueOf(payeeInfo.getOrDefault("name", ""));
+                    identityType = String.valueOf(payeeInfo.getOrDefault("identity_type", ""));
+                } catch (Exception ignored) {
+                    // ignore invalid JSON
+                }
+            }
+            row.put("收款方姓名", name);
+            row.put("收款方类型", payeeTypeMap.getOrDefault(identityType, identityType));
+            row.put("备注", r.getRemark() != null ? r.getRemark() : "");
+            row.put("失败原因", r.getErrorMsg() != null ? r.getErrorMsg() : "");
+            listData.add(row);
+        }
+
+        Map<String, String> mappingDict = new LinkedHashMap<>();
+        mappingDict.put("序号", "序号");
+        mappingDict.put("订单号", "订单号");
+        mappingDict.put("商户订单号", "商户订单号");
+        mappingDict.put("金额(元)", "金额(元)");
+        mappingDict.put("收款方姓名", "收款方姓名");
+        mappingDict.put("收款方类型", "收款方类型");
+        mappingDict.put("状态", "状态");
+        mappingDict.put("转账标题", "转账标题");
+        mappingDict.put("创建时间", "创建时间");
+        mappingDict.put("备注", "备注");
+        mappingDict.put("失败原因", "失败原因");
+
+        log.info("导出转账记录报表: {} 条", listData.size());
+        return ExcelUtil.exportToExcel(listData, mappingDict);
+    } catch (Exception e) {
+        log.error("导出转账记录失败", e);
+        throw new RuntimeException("导出转账记录失败: " + e.getMessage());
+    }
+}
+```
+
+- [ ] **Step 3: 补充缺失 import**
+
+`AccountService.java` 顶部 java.time 包 import 区若无 `java.time.ZoneId`,在 `import java.time.OffsetDateTime;` 后添加:
+
+```java
+import java.time.ZoneId;
+```
+
+(若已存在则跳过此步;`DateTimeFormatter` 若缺失同样补 `import java.time.format.DateTimeFormatter;`)
+
+- [ ] **Step 4: 编译验证**
+
+Run: `cd ../java && mvn -q compile -DskipTests`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd ../java && git add src/main/java/com/payment/platform/module/payment/account/service/AccountService.java src/main/java/com/payment/platform/module/payment/account/controller/AccountController.java && git commit -m "feat: 转账导出接口对齐表单筛选条件,导出列加备注/失败原因,时间格式东8,类型翻译补齐"
+```
+
+---
+
+### Task 3: 前端 API 参数更新(transferList + exportTransferReport)
+
+**Files:**
+- Modify: `src/api/module_payment/account.ts:184-197`(transferList)
+- Modify: `src/api/module_payment/account.ts:230-241`(exportTransferReport)
+
+**Interfaces:**
+- Produces: `AccountAPI.transferList(params)` 新增可选 `start_time`/`end_time`;`AccountAPI.exportTransferReport(params)` 参数改为 6 个可选字段(无必填)
+
+- [ ] **Step 1: transferList 类型加时间参数**
+
+```ts
+transferList(params: {
+  page_no?: number;
+  page_size?: number;
+  out_biz_no?: string;
+  status?: string;
+  tenant_id?: number;
+  enterprise_id?: string;
+  start_time?: string;
+  end_time?: string;
+}) {
+  return request<ApiResponse<TransferListResp>>({
+    url: `${API_PATH}/transfer`,
+    method: "get",
+    params,
+  });
+},
+```
+
+- [ ] **Step 2: exportTransferReport 参数类型替换**
+
+```ts
+exportTransferReport(params: {
+  out_biz_no?: string;
+  status?: string;
+  tenant_id?: number;
+  enterprise_id?: string;
+  start_time?: string;
+  end_time?: string;
+}) {
+  return request({
+    url: `${API_PATH}/transfer/export`,
+    method: "get",
+    params,
+    responseType: "blob",
+  });
+},
+```
+
+- [ ] **Step 3: 类型检查**
+
+Run: `pnpm ts:check 2>&1 | grep -c "src/api/module_payment/account.ts"`
+Expected: 0(该文件无类型错误;项目其他文件存在既有错误基线,忽略)
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/api/module_payment/account.ts && git commit -m "feat: 转账导出API参数对齐表单筛选(6条件全可选),transferList支持时间筛选"
+```
+
+---
+
+### Task 4: 前端筛选表单(时间选择器 + 控件加宽 + 查询/重置适配)
+
+**Files:**
+- Modify: `src/views/module_payment/account/index.vue:1158-1163`(transferSearchForm 定义)
+- Modify: `src/views/module_payment/account/index.vue:299-326`(筛选表单模板)
+- Modify: `src/views/module_payment/account/index.vue:1762-1786`(重置 + fetchTransferList)
+- Modify: `src/views/module_payment/account/index.vue`(script 顶部 import 区,加 dayjs)
+- Modify: `src/views/module_payment/account/index.vue:353`(创建时间列 formatter)
+
+**Interfaces:**
+- Produces: `transferSearchForm` 新增 `dateRange: string[] | null`、`start_time: string | undefined`、`end_time: string | undefined`;`handleTransferDateChange()`;`fetchTransferList` 传时间参数
+
+- [ ] **Step 1: transferSearchForm 增加时间字段**
+
+`index.vue:1158-1163` 改为:
+
+```ts
+const transferSearchForm = reactive({
+  out_biz_no: "",
+  status: "",
+  tenant_id: undefined as number | undefined,
+  enterprise_id: undefined as string | undefined,
+  dateRange: null as string[] | null,
+  start_time: undefined as string | undefined,
+  end_time: undefined as string | undefined,
+});
+```
+
+- [ ] **Step 2: 表单模板加时间选择器 + 控件加宽**
+
+`index.vue:299-326` 的筛选表单:订单号 input 加 `style="width: 220px"`,状态/租户/企业 select 加 `style="width: 180px"`,并在企业 select 的 `</el-form-item>` 后、按钮 `</el-form-item>` 前插入:
+
+```html
+<el-form-item label="时间范围">
+  <el-date-picker
+    v-model="transferSearchForm.dateRange"
+    type="daterange"
+    range-separator="至"
+    start-placeholder="开始日期"
+    end-placeholder="结束日期"
+    value-format="YYYY-MM-DD"
+    style="width: 280px"
+    @change="handleTransferDateChange"
+  />
+</el-form-item>
+```
+
+- [ ] **Step 3: 新增 handleTransferDateChange(仿 handleConsumeDateChange)**
+
+在 `handleTransferSearchReset`(约 1762 行)前添加:
+
+```ts
+function handleTransferDateChange() {
+  if (transferSearchForm.dateRange && transferSearchForm.dateRange.length === 2) {
+    transferSearchForm.start_time = (transferSearchForm.dateRange as any)[0];
+    transferSearchForm.end_time = (transferSearchForm.dateRange as any)[1];
+  } else {
+    transferSearchForm.start_time = undefined;
+    transferSearchForm.end_time = undefined;
+  }
+}
+```
+
+- [ ] **Step 4: 重置清空时间条件**
+
+`handleTransferSearchReset`(`index.vue:1762-1768`)加三行:
+
+```ts
+function handleTransferSearchReset() {
+  transferSearchForm.out_biz_no = "";
+  transferSearchForm.status = "";
+  transferSearchForm.tenant_id = undefined;
+  transferSearchForm.enterprise_id = undefined;
+  transferSearchForm.dateRange = null;
+  transferSearchForm.start_time = undefined;
+  transferSearchForm.end_time = undefined;
+  handleTransferSearch();
+}
+```
+
+- [ ] **Step 5: fetchTransferList 传时间参数**
+
+`index.vue:1773-1780` 的请求参数加两行:
+
+```ts
+    const res = await AccountAPI.transferList({
+      page_no: transferPage.page_no,
+      page_size: transferPage.page_size,
+      out_biz_no: transferSearchForm.out_biz_no || undefined,
+      status: transferSearchForm.status || undefined,
+      tenant_id: transferSearchForm.tenant_id || undefined,
+      enterprise_id: transferSearchForm.enterprise_id || undefined,
+      start_time: transferSearchForm.start_time || undefined,
+      end_time: transferSearchForm.end_time || undefined,
+    });
+```
+
+- [ ] **Step 6: script 顶部引入 dayjs**
+
+`<script setup lang="ts">` 的 import 区(文件顶部)添加:
+
+```ts
+import dayjs from "dayjs";
+```
+
+(若已存在则跳过)
+
+- [ ] **Step 7: 创建时间列 formatter**
+
+`index.vue:353` 的创建时间列替换为:
+
+```html
+<el-table-column prop="created_time" label="创建时间" min-width="160">
+  <template #default="{ row }">
+    {{ row.created_time ? dayjs(row.created_time).format("YYYY-MM-DD HH:mm:ss") : "" }}
+  </template>
+</el-table-column>
+```
+
+- [ ] **Step 8: 类型检查**
+
+Run: `pnpm ts:check 2>&1 | grep -c "module_payment/account/index.vue"`
+Expected: 8(既有错误基线,无新增——对比基线:修改前该文件恰有 8 条既有错误,全部在改动行之外)
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add src/views/module_payment/account/index.vue && git commit -m "feat: 转账记录筛选表单增加时间范围选择器,控件加宽,创建时间列格式化为东8"
+```
+
+---
+
+### Task 5: 前端下载报表按钮(改写旧导出 + 清理死代码)
+
+**Files:**
+- Modify: `src/views/module_payment/account/index.vue:288-295`(注释按钮块 → 启用)
+- Delete: `src/views/module_payment/account/index.vue:730-761`(下载转账报表 dialog 模板)
+- Delete: `src/views/module_payment/account/index.vue:802-806`(downloadLoading/showDownloadMenu/selectedQuickOption/customDateRange 状态)
+- Delete: `src/views/module_payment/account/index.vue:2049-2145`(handleDownload/calculateTimeRange/formatDateTime/handleCustomDateChange)
+- Modify: `src/views/module_payment/account/index.vue`(script 新增 handleExportTransfer + transferExportLoading)
+
+**Interfaces:**
+- Consumes: Task 3 的 `AccountAPI.exportTransferReport`(6 可选参数)、Task 4 的 `transferSearchForm`(含时间字段)
+- Produces: `transferExportLoading: Ref<boolean>`;`handleExportTransfer()` — 按当前表单筛选导出 `转账记录.xlsx`
+
+- [ ] **Step 1: 启用下载按钮(替换注释块)**
+
+`index.vue:288-295` 的注释块替换为:
+
+```html
+                <el-button
+                  type="primary"
+                  icon="Download"
+                  class="ml-auto"
+                  :loading="transferExportLoading"
+                  v-hasPerm="['module_payment:account:transfer:list']"
+                  @click="handleExportTransfer"
+                >
+                  下载报表
+                </el-button>
+```
+
+- [ ] **Step 2: 删除旧导出 dialog 模板**
+
+删除 `index.vue:730-761` 整个 `<el-dialog title="下载转账报表" ...>` 块(含 `</el-dialog>`),其内部引用 `showDownloadMenu`/`selectedQuickOption`/`customDateRange`/`handleDownload`/`downloadLoading`/`handleCustomDateChange` 的状态与函数在 Step 3-5 同步删除。
+
+- [ ] **Step 3: 删除旧状态**
+
+删除 `index.vue:802-806`:
+
+```ts
+const downloadLoading = ref(false);
+const showDownloadMenu = ref(false);
+const selectedQuickOption = ref("today");
+const customDateRange = ref<Date[] | null>(null);
+```
+
+- [ ] **Step 4: 新增 handleExportTransfer(替换 handleDownload 等死代码)**
+
+删除 `index.vue:2049-2145`(`handleDownload`、`calculateTimeRange`、`formatDateTime`、`handleCustomDateChange` 整个函数块),在原位置(`</script>` 前)添加:
+
+```ts
+const transferExportLoading = ref(false);
+
+async function handleExportTransfer() {
+  transferExportLoading.value = true;
+  try {
+    const res = await AccountAPI.exportTransferReport({
+      out_biz_no: transferSearchForm.out_biz_no || undefined,
+      status: transferSearchForm.status || undefined,
+      tenant_id: transferSearchForm.tenant_id || undefined,
+      enterprise_id: transferSearchForm.enterprise_id || undefined,
+      start_time: transferSearchForm.start_time || undefined,
+      end_time: transferSearchForm.end_time || undefined,
+    });
+    const blob = new Blob([res.data], {
+      type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+    });
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement("a");
+    a.href = url;
+    a.download = "转账记录.xlsx";
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    window.URL.revokeObjectURL(url);
+    ElMessage.success("导出成功");
+  } catch (error) {
+    ElMessage.error("导出失败,请稍后重试");
+  } finally {
+    transferExportLoading.value = false;
+  }
+}
+```
+
+- [ ] **Step 5: 确认无残留引用**
+
+Run: `grep -nE "showDownloadMenu|customDateRange|selectedQuickOption|downloadLoading|handleDownload|calculateTimeRange|formatDateTime|handleCustomDateChange" src/views/module_payment/account/index.vue`
+Expected: 无输出(全部清理干净)
+
+- [ ] **Step 6: 类型检查**
+
+Run: `pnpm ts:check 2>&1 | grep -c "module_payment/account/index.vue"`
+Expected: 8(既有基线,无新增)
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/views/module_payment/account/index.vue && git commit -m "feat: 转账记录下载报表按钮接入导出接口(跟随表单筛选),清理旧导出死代码"
+```
+
+---
+
+### Task 6: 端到端验证(Playwright)
+
+**Files:** 无(验证任务,不产生代码改动)
+
+- [ ] **Step 1: 启动后端与前端**
+
+Run: `cd ../java && mvn spring-boot:run`(后台);`pnpm dev`(后台,默认端口 5173,具体以 `.env`/vite 配置为准)
+
+- [ ] **Step 2: Playwright 验证筛选联动**
+
+用 Playwright MCP 登录后进入"资金账户 → 转账记录" tab:
+1. 选择时间范围(如近 7 天)→ 点查询 → 断言列表只显示范围内数据
+2. 组合筛选(状态=成功 + 时间范围)→ 查询 → 列表与筛选一致
+3. 点重置 → 断言时间范围被清空、列表恢复全量
+
+- [ ] **Step 3: Playwright 验证导出**
+
+点击"下载报表"按钮,捕获下载事件:
+1. 断言下载文件名为 `转账记录.xlsx`
+2. 用脚本读取 xlsx(exceljs)验证:表头恰 11 列(含备注、失败原因);"创建时间"值为 `YYYY-MM-DD HH:mm:ss` 格式且为东8墙钟时间;"收款方类型"为中文(支付宝账户/银行卡);数据条数与当前筛选下的列表一致
+
+- [ ] **Step 4: 回归检查**
+
+快速检查同页其他功能(单笔转账、批量转账模板下载)不受影响;`pnpm ts:check` 全量错误数不高于改动前基线

+ 65 - 0
docs/superpowers/specs/2026-08-11-transfer-export-design.md

@@ -0,0 +1,65 @@
+# 转账记录导出 Excel + 时间筛选 + 导出修复 — 设计文档
+
+日期:2026-08-11
+状态:已批准(用户逐项确认)
+
+## 背景与目标
+
+转账管理页面(`module_payment/account/index.vue` 的"转账记录" tab)增加导出 Excel 能力,导出的数据跟随表单筛选条件。同时修复两个既有问题:导出时间格式/时区、收款方类型翻译缺失。
+
+## 现状
+
+- 后端已有 `GET /transfer/export`(`AccountController.java:238`,权限 `module_payment:account:transfer:list`),但参数为 `start_time`/`end_time`(必填)+ `enterprise_id`,与页面筛选(`out_biz_no`/`status`/`tenant_id`/`enterprise_id`,无时间条件)不匹配
+- 前端"下载报表"按钮被注释(`index.vue:288-295`),`exportTransferReport` API 已定义(`account.ts:230`,`responseType: "blob"`)但未接线——dead code
+- `transferExport` 无其他调用方,参数改动无副作用
+- 导出"创建时间"用 `getCreatedTime().toString()`(`AccountService.java:332`),输出 ISO 格式含 T/偏移;`pay_transfer.created_time` 为 `timestamp without time zone`,JDBC 读出的偏移标记取决于 JVM 时区(可能为 Z),导致时间看起来不是东8
+- 导出"收款方类型" map(`AccountService.java:316`)只有 `ALIPAY_ACCOUNT`/`BANK_CARD`,而实际存储的 `identity_type` 是前端提交的 `ALIPAY_USER_ID`/`ALIPAY_LOGON_ID`/`BANKCARD_ACCOUNT`(转账表单下拉、批量转账 `index.vue:1446`),map 不命中输出英文
+
+## 设计决策(用户已确认)
+
+1. 实现方式:改造现有后端 `transferExport` 接口,前端恢复"下载报表"按钮
+2. 导出列:现 9 列 + 备注 + 失败原因(共 11 列)
+3. 页面"转账记录"筛选表单新增时间范围筛选,列表查询与导出同步适配
+4. 筛选表单控件加宽,解决条件看不清问题
+5. 导出时间格式化为东8 `yyyy-MM-dd HH:mm:ss`;页面"创建时间"列加 formatter 同样式
+6. 收款方类型 map 补齐实际存储值
+7. 时间解析沿用 `parseDateTime` 既有语义(结束日期解析为当天 00:00:00,与消费记录一致)
+8. 空数据导出空 sheet,不特判提示
+
+## 后端改动
+
+### `AccountController.java`
+
+- `transferList`(:77):新增 `start_time`/`end_time` 选填参数,透传 service
+- `transferExport`(:238):参数改为 6 个全可选——`out_biz_no`/`status`/`tenant_id`/`enterprise_id`/`start_time`/`end_time`;权限不变
+
+### `AccountService.java`
+
+- `transferList`(:190):时间条件 `w.ge/le(TransferEntity::getCreatedTime, parseDateTime(...))`,条件语义与既有四条件一致(`eq` 精确匹配)
+- `transferExport`(:295):
+  - 查询条件与 `transferList` 逐条一致(6 条件,全部可选)
+  - 导出列 11 列:序号、订单号、商户订单号、金额(元)、收款方姓名、收款方类型、状态、转账标题、创建时间、备注、失败原因
+  - 失败原因取 `errorMsg`(非空输出,空则空串)
+  - "创建时间"格式化为东8:`createdTime.atZoneSameInstant(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))`
+  - "收款方类型" map 补齐:`ALIPAY_USER_ID`/`ALIPAY_LOGON_ID`/`ALIPAY_ACCOUNT` → 支付宝账户;`BANKCARD_ACCOUNT`/`BANK_CARD` → 银行卡
+
+## 前端改动
+
+### `account.ts`
+
+- `exportTransferReport`(:230)参数类型更新:`out_biz_no?`/`status?`/`tenant_id?`/`enterprise_id?`/`start_time?`/`end_time?`,去掉必填的 `start_time`/`end_time`
+
+### `index.vue`(转账记录区域)
+
+- `transferSearchForm` 增加 `dateRange`/`start_time`/`end_time`(模式复用消费记录 `consumeSearchForm` :1806-1824)
+- 筛选表单加 `el-date-picker type="daterange"`(`value-format="YYYY-MM-DD"`,消费记录同款 :415-425)
+- 筛选控件加宽:订单号 input 220px、状态/租户/企业 select 180px、时间范围 280px
+- `handleTransferSearchReset` 清空时间条件
+- `fetchTransferList` 传 `start_time`/`end_time`
+- 恢复"下载报表"按钮(原注释位 :288-295):`v-hasPerm="['module_payment:account:transfer:list']"`、导出中 loading、失败 `ElMessage.error`、成功 blob → `a.download` 下载 `转账记录.xlsx`
+- "创建时间"表格列加 formatter:显示 `YYYY-MM-DD HH:mm:ss`(东8墙钟,与导出一致)
+
+## 验证计划
+
+- 后端:`mvn compile`;curl 按筛选条件(含时间)调用 `/transfer/export`,核对导出列、筛选结果、时间格式(东8 `yyyy-MM-dd HH:mm:ss`)、类型翻译(支付宝账户/银行卡)
+- 前端:`ts:check` 确认无新增错误(项目存在既有错误基线);Playwright 验证筛选时间条件查询、导出按钮下载文件

+ 2 - 1
frontend/src/api/module_payment/account.ts

@@ -241,7 +241,8 @@ export const AccountAPI = {
   },
 
   downloadReceipt(params: {
-    enterprise_id: string;
+    // enterprise_id 可选 — 后端支持仅凭 order_no 查库推导(downloadReceiptByOrderNo)
+    enterprise_id?: string;
     order_no: string;
   }) {
     return request<ApiResponse<ReceiptQueryOutSchema>>({

+ 5 - 6
frontend/src/views/module_payment/account/index.vue

@@ -1904,7 +1904,7 @@ async function handleDownloadReceipt(row: any) {
     } else if (status === "FAIL") {
       ElMessage.error(`回单生成失败: ${error_message || "未知错误"}`);
     } else if (status === "PROCESS" || status === "INIT") {
-      await pollForReceipt(file_id);
+      await pollForReceipt(row.order_no);
     } else {
       ElMessage.info("回单状态未知,请稍后尝试");
     }
@@ -1915,7 +1915,9 @@ async function handleDownloadReceipt(row: any) {
   }
 }
 
-async function pollForReceipt(file_id: string) {
+// 轮询回单状态 — 只依赖 order_no(后端 downloadReceiptByOrderNo 自动推导 enterprise_id,
+// 避免未选企业时 enterprise_id 为 undefined 拼出 /receipt/undefined/{file_id})
+async function pollForReceipt(order_no: string) {
   const maxRetries = 3;
   const retryInterval = 3000;
   let retryCount = 0;
@@ -1925,10 +1927,7 @@ async function pollForReceipt(file_id: string) {
     retryCount++;
 
     try {
-      const res = await AccountAPI.queryReceipt({
-        enterprise_id: currentEnterpriseId.value!,
-        file_id,
-      });
+      const res = await AccountAPI.downloadReceipt({ order_no });
 
       const { status, download_url, error_message } = res.data.data;
 

+ 36 - 11
java/src/main/java/com/payment/platform/module/payment/account/service/AccountService.java

@@ -381,8 +381,13 @@ public class AccountService {
     }
 
     /**
-     * 申请回单 — 对应 Python apply_receipt_service (L824-893)
-     * 调用 alipay.commerce.ec.trans.receipt.apply
+     * 申请回单 — ISV 模式(对应 Python apply_receipt_service L824-893)
+     * 调用 alipay.data.bill.ereceiptagent.apply(基于协议获取账单回单)
+     *
+     * 2026-08 从 alipay.commerce.ec.trans.receipt.apply(商户直连企业码模式)切换为
+     * ISV 协议代理模式。前端仍传 enterprise_id + order_no,后端内部映射:
+     *   key = TransferEntity.fundOrderId(支付宝 pay_fund_order_id)
+     *   agreement_no = AccountEntity.agreementNo(ISV 与商户授权协议号)
      */
     public Map<String, String> receiptApply(Map<String, Object> body) {
         String enterpriseId = (String) body.get("enterprise_id");
@@ -390,6 +395,15 @@ public class AccountService {
         if (enterpriseId == null || orderNo == null)
             throw new BusinessException(400, "enterprise_id 和 order_no 不能为空");
 
+        // 从转账记录取支付宝资金单号 pay_fund_order_id 作为申请 key
+        TransferEntity transfer = transferMapper.selectOne(
+                new LambdaQueryWrapper<TransferEntity>().eq(TransferEntity::getOrderNo, orderNo));
+        if (transfer == null)
+            throw new BusinessException(400, "未找到对应转账记录: " + orderNo);
+        String fundOrderId = transfer.getFundOrderId();
+        if (fundOrderId == null || fundOrderId.isBlank())
+            throw new BusinessException(400, "该转账记录缺少支付宝资金单号(pay_fund_order_id),无法申请回单");
+
         // Redis 缓存 key
         String cacheKey = "receipt:" + enterpriseId + ":" + orderNo;
         String cachedFileId = (String) redisTemplate.opsForValue().get(cacheKey);
@@ -399,11 +413,12 @@ public class AccountService {
         }
 
         try {
-            var model = new com.alipay.api.domain.AlipayCommerceEcTransReceiptApplyModel();
-            model.setEnterpriseId(enterpriseId);
-            model.setOrderNo(orderNo);
+            var model = new com.alipay.api.domain.AlipayDataBillEreceiptagentApplyModel();
+            model.setType("FUND_DETAIL");
+            model.setKey(fundOrderId);
+            model.setAgreementNo(getAgreementNo(enterpriseId));
 
-            var request = new com.alipay.api.request.AlipayCommerceEcTransReceiptApplyRequest();
+            var request = new com.alipay.api.request.AlipayDataBillEreceiptagentApplyRequest();
             request.setBizModel(model);
 
             var response = alipayClientFactory.getClient(enterpriseId).certificateExecute(request);
@@ -426,16 +441,17 @@ public class AccountService {
     }
 
     /**
-     * 查询回单状态 — 对应 Python query_receipt_service (L896-944)
-     * 调用 alipay.commerce.ec.trans.receipt.query
+     * 查询回单状态 — ISV 模式(对应 Python query_receipt_service L896-944)
+     * 调用 alipay.data.bill.accountbookereceipt.query
+     * 2026-08 从 alipay.commerce.ec.trans.receipt.query 切换,参数由 enterprise_id+file_id 映射为 agreement_no+file_id
      */
     public Map<String, Object> queryReceipt(String enterpriseId, String fileId) {
         try {
-            var model = new com.alipay.api.domain.AlipayCommerceEcTransReceiptQueryModel();
-            model.setEnterpriseId(enterpriseId);
+            var model = new com.alipay.api.domain.AlipayDataBillAccountbookereceiptQueryModel();
             model.setFileId(fileId);
+            model.setAgreementNo(getAgreementNo(enterpriseId));
 
-            var request = new com.alipay.api.request.AlipayCommerceEcTransReceiptQueryRequest();
+            var request = new com.alipay.api.request.AlipayDataBillAccountbookereceiptQueryRequest();
             request.setBizModel(model);
 
             var response = alipayClientFactory.getClient(enterpriseId).certificateExecute(request);
@@ -455,6 +471,15 @@ public class AccountService {
         }
     }
 
+    /** 获取商户签约协议号(ISV 与商户授权协议号),未签约时抛错 */
+    private String getAgreementNo(String enterpriseId) {
+        AccountEntity account = accountMapper.selectOne(
+                new LambdaQueryWrapper<AccountEntity>().eq(AccountEntity::getEnterpriseId, enterpriseId));
+        if (account == null || account.getAgreementNo() == null || account.getAgreementNo().isBlank())
+            throw new BusinessException(400, "该商户未完成签约,缺少协议号 agreement_no");
+        return account.getAgreementNo();
+    }
+
     /**
      * 获取回单下载链接(封装 apply + query)
      * 对应 Python receipt/download 端点 (controller L521-553)