2026-08-11-transfer-export.md 23 KB

转账记录导出 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/endTimeyyyy-MM-ddyyyy-MM-dd HH:mm:ss,可空)

  • [ ] Step 1: service 加时间过滤参数

AccountService.transferList 签名与查询条件改为:

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 改为:

@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

    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 整体替换为:

@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 omExcelUtil.exportToExcel 用法):

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; 后添加:

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

    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_timeAccountAPI.exportTransferReport(params) 参数改为 6 个可选字段(无必填)

  • [ ] Step 1: transferList 类型加时间参数

    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 参数类型替换

    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

    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[] | nullstart_time: string | undefinedend_time: string | undefinedhandleTransferDateChange()fetchTransferList 传时间参数

  • [ ] Step 1: transferSearchForm 增加时间字段

index.vue:1158-1163 改为:

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> 前插入:

<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 行)前添加:

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: 重置清空时间条件

handleTransferSearchResetindex.vue:1762-1768)加三行:

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 的请求参数加两行:

    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 区(文件顶部)添加:

import dayjs from "dayjs";

(若已存在则跳过)

  • Step 7: 创建时间列 formatter

index.vue:353 的创建时间列替换为:

<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

    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 的注释块替换为:

                <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

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-2145handleDownloadcalculateTimeRangeformatDateTimehandleCustomDateChange 整个函数块),在原位置(</script> 前)添加:

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

    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 全量错误数不高于改动前基线