From 5ab4ac42d1e36b3a760663f359f075193e52a2da Mon Sep 17 00:00:00 2001 From: DaxPay Dev Date: Sun, 19 Jul 2026 11:07:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=E5=B7=A5=E4=BD=9C=E5=8F=B0/?= =?UTF-8?q?=E5=88=86=E6=9E=90=E9=A1=B5=E4=BA=A4=E6=98=93=E8=81=9A=E5=90=88?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E6=8E=A5=E5=8F=A3=EF=BC=888=20=E7=BB=B4?= =?UTF-8?q?=E5=BA=A6=E5=85=A8=E9=87=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TradeOverviewResult 加 totalOrders + 5 个 prev* 字段, 支持区间对比算环比 - TradeTrendItemResult 加 count, 支持交易笔数趋势 - 新增 5 个 Result: RefundTrend/ProviderSuccess/HourlyDist/AmountRange/MerchantRank - TradeReportMapper 新增 6 个聚合: · totalOrders 按 create_time 口径(失败单也计入分母) · providerSuccess 用 FILTER + NULLIF 算成功率 · amountRange 用 CASE WHEN 分 5 桶(0-50/50-200/200-1000/1000-5000/5000+) · merchantRank JOIN mch_info 取商户名 - DashboardTradeService: overview 区间模式 + 上期对比, 各维度补零/补桶/算占比 - DashboardTradeController: 8 个 endpoint, days 与 start/end 二选一 - pay_trade 加 provider 冗余列, PayUniHandleService 支付成功 sync 时同步写 - update-tables.sql 加 provider 列 DDL + 历史 normal/gateway 数据回填 --- _config/sql/update-tables.sql | 38 ++ .../dashboard/DashboardTradeController.java | 156 ++++++++ .../dashboard/DashboardTradeService.java | 351 ++++++++++++++++++ .../payment/trade/order/entity/PayTrade.java | 8 +- .../trade/report/dao/TradeReportMapper.java | 193 ++++++++++ .../report/result/AmountRangeItemResult.java | 23 ++ .../report/result/HourlyDistItemResult.java | 27 ++ .../report/result/MerchantRankItemResult.java | 35 ++ .../report/result/ProviderDistItemResult.java | 27 ++ .../result/ProviderSuccessItemResult.java | 23 ++ .../report/result/RefundTrendItemResult.java | 27 ++ .../report/result/TradeOverviewResult.java | 67 ++++ .../report/result/TradeTrendItemResult.java | 27 ++ .../pay/common/PayUniHandleService.java | 12 +- 14 files changed, 1009 insertions(+), 5 deletions(-) create mode 100644 daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/dashboard/DashboardTradeController.java create mode 100644 daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/dashboard/DashboardTradeService.java create mode 100644 daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/dao/TradeReportMapper.java create mode 100644 daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/AmountRangeItemResult.java create mode 100644 daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/HourlyDistItemResult.java create mode 100644 daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/MerchantRankItemResult.java create mode 100644 daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/ProviderDistItemResult.java create mode 100644 daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/ProviderSuccessItemResult.java create mode 100644 daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/RefundTrendItemResult.java create mode 100644 daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/TradeOverviewResult.java create mode 100644 daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/TradeTrendItemResult.java diff --git a/_config/sql/update-tables.sql b/_config/sql/update-tables.sql index 847529195..9765b76cb 100644 --- a/_config/sql/update-tables.sql +++ b/_config/sql/update-tables.sql @@ -23,3 +23,41 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_mch_app_info_default CREATE UNIQUE INDEX IF NOT EXISTS uk_mch_store_info_default ON mch_store_info (mch_no) WHERE default_store = TRUE AND deleted = FALSE; + +-- ============================================================ +-- pay_trade 冗余支付渠道字段(升级脚本) +-- ============================================================ +-- 背景: +-- 渠道分布报表/资金列表筛选需要按支付渠道(provider)维度统计, +-- 原 pay_trade 仅冗余 channelMchNo, 无渠道字段, 报表 SQL 报错或需 JOIN 容器表. +-- 按"轻量组织冗余"哲学(source/channelMchNo/storeNo/provider), 加 provider 列, +-- 与现有冗余字段并列; 权威仍在业务容器(NormalPayOrder/GatewayPayOrder). +-- 写入时机: +-- 支付成功 sync 回执时由 PayUniHandleService.applyXxxSyncReceipts 同步写容器+资金凭证. +-- 历史数据: +-- 通过 container_id JOIN 容器表回填(normal/gateway 两种主流 trade_type). +-- 其他 trade_type 历史无 provider 数据可回填, 留 NULL, 不影响新交易. +-- ============================================================ + +-- 1. 加列(允许 NULL, 历史数据回填前为空) +ALTER TABLE pay_trade ADD COLUMN IF NOT EXISTS provider varchar(32); + +COMMENT ON COLUMN pay_trade.provider IS '支付渠道(冗余自容器, 支付成功sync后回填; 权威在容器 provider)'; + +-- 2. 历史数据回填: normal 容器 +UPDATE pay_trade t +SET provider = o.provider +FROM pay_normal_order o +WHERE t.container_id = o.id + AND t.trade_type = 'normal' + AND t.provider IS NULL + AND o.provider IS NOT NULL; + +-- 3. 历史数据回填: gateway 容器 +UPDATE pay_trade t +SET provider = o.provider +FROM pay_gateway_order o +WHERE t.container_id = o.id + AND t.trade_type = 'gateway' + AND t.provider IS NULL + AND o.provider IS NOT NULL; diff --git a/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/dashboard/DashboardTradeController.java b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/dashboard/DashboardTradeController.java new file mode 100644 index 000000000..5a666b013 --- /dev/null +++ b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/dashboard/DashboardTradeController.java @@ -0,0 +1,156 @@ +package cn.daxpay.open.payment.admin.controller.dashboard; + +import cn.daxpay.open.platform.core.rest.Res; +import cn.daxpay.open.platform.core.rest.result.Result; +import cn.daxpay.open.payment.admin.service.dashboard.DashboardTradeService; +import cn.daxpay.open.payment.trade.report.result.AmountRangeItemResult; +import cn.daxpay.open.payment.trade.report.result.HourlyDistItemResult; +import cn.daxpay.open.payment.trade.report.result.MerchantRankItemResult; +import cn.daxpay.open.payment.trade.report.result.ProviderDistItemResult; +import cn.daxpay.open.payment.trade.report.result.ProviderSuccessItemResult; +import cn.daxpay.open.payment.trade.report.result.RefundTrendItemResult; +import cn.daxpay.open.payment.trade.report.result.TradeOverviewResult; +import cn.daxpay.open.payment.trade.report.result.TradeTrendItemResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/// # 工作台/分析页交易统计(管理端) +/// +/// 面向运营端工作台仪表盘与分析页的聚合统计: +/// 概览(含环比)、交易趋势、退款趋势、支付渠道分布、渠道成功率、时段分布、金额区间、商户排名。 +/// +/// ## 参数约定 +/// - **天数模式**: `days` 参数(近 N 天含今天, 钳制到 [1, 365]) +/// - **区间模式**: `start` + `end` 参数(yyyy-MM-dd, 均包含, 半开区间处理) +/// - 两者二选一: 同时传时优先区间模式 +/// +/// 不挂菜单权限码: 工作台/分析页为登录即达的页面, 任何已认证运营用户均可查看聚合统计。 +/// 敏感维度(商户/金额明细)仍由对应业务单管理页与权限码控制。 +@Tag(name = "工作台/分析页交易统计") +@Validated +@RestController +@RequestMapping("/admin/dashboard/trade") +@RequiredArgsConstructor +public class DashboardTradeController { + + private final DashboardTradeService dashboardTradeService; + + // ===== 概览 ===== + + /// 交易概览: 支持今日/昨日快捷模式 + 自定义区间模式(含上期对比用于环比) + /// 同时传 start/end 时按区间模式; 否则按 date 快捷模式 + @Operation(summary = "交易概览(今日/昨日或自定义区间)") + @GetMapping("/overview") + public Result overview( + @RequestParam(required = false) String date, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end) { + if (start != null && end != null) { + return Res.ok(dashboardTradeService.overview(start, end)); + } + return Res.ok(dashboardTradeService.overview(date != null ? date : "today")); + } + + // ===== 趋势 ===== + + @Operation(summary = "交易趋势(指定天数或自定义区间)") + @GetMapping("/trend") + public Result> trend( + @RequestParam(defaultValue = "7") int days, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end) { + if (start != null && end != null) { + return Res.ok(dashboardTradeService.trend(start, end)); + } + return Res.ok(dashboardTradeService.trend(days)); + } + + @Operation(summary = "退款趋势(指定天数或自定义区间)") + @GetMapping("/refund-trend") + public Result> refundTrend( + @RequestParam(defaultValue = "7") int days, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end) { + if (start != null && end != null) { + return Res.ok(dashboardTradeService.refundTrend(start, end)); + } + return Res.ok(dashboardTradeService.refundTrend(days)); + } + + // ===== 渠道 ===== + + @Operation(summary = "支付渠道分布(指定天数或自定义区间)") + @GetMapping("/provider-dist") + public Result> providerDist( + @RequestParam(defaultValue = "30") int days, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end) { + if (start != null && end != null) { + return Res.ok(dashboardTradeService.providerDist(start, end)); + } + return Res.ok(dashboardTradeService.providerDist(days)); + } + + @Operation(summary = "支付渠道成功率(指定天数或自定义区间)") + @GetMapping("/provider-success") + public Result> providerSuccess( + @RequestParam(defaultValue = "30") int days, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end) { + if (start != null && end != null) { + return Res.ok(dashboardTradeService.providerSuccess(start, end)); + } + return Res.ok(dashboardTradeService.providerSuccess(days)); + } + + // ===== 时段 ===== + + @Operation(summary = "24小时时段分布(指定天数或自定义区间)") + @GetMapping("/hourly-dist") + public Result> hourlyDist( + @RequestParam(defaultValue = "7") int days, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end) { + if (start != null && end != null) { + return Res.ok(dashboardTradeService.hourlyDist(start, end)); + } + return Res.ok(dashboardTradeService.hourlyDist(days)); + } + + // ===== 金额区间 ===== + + @Operation(summary = "金额区间分桶(指定天数或自定义区间)") + @GetMapping("/amount-range") + public Result> amountRange( + @RequestParam(defaultValue = "7") int days, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end) { + if (start != null && end != null) { + return Res.ok(dashboardTradeService.amountRange(start, end)); + } + return Res.ok(dashboardTradeService.amountRange(days)); + } + + // ===== 商户排名 ===== + + @Operation(summary = "商户交易额排名(指定天数或自定义区间)") + @GetMapping("/merchant-rank") + public Result> merchantRank( + @RequestParam(defaultValue = "7") int days, + @RequestParam(defaultValue = "10") int limit, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end) { + if (start != null && end != null) { + return Res.ok(dashboardTradeService.merchantRank(start, end, limit)); + } + return Res.ok(dashboardTradeService.merchantRank(days, limit)); + } +} diff --git a/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/dashboard/DashboardTradeService.java b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/dashboard/DashboardTradeService.java new file mode 100644 index 000000000..679012502 --- /dev/null +++ b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/dashboard/DashboardTradeService.java @@ -0,0 +1,351 @@ +package cn.daxpay.open.payment.admin.service.dashboard; + +import cn.daxpay.open.payment.trade.report.dao.TradeReportMapper; +import cn.daxpay.open.payment.trade.report.result.AmountRangeItemResult; +import cn.daxpay.open.payment.trade.report.result.HourlyDistItemResult; +import cn.daxpay.open.payment.trade.report.result.MerchantRankItemResult; +import cn.daxpay.open.payment.trade.report.result.ProviderDistItemResult; +import cn.daxpay.open.payment.trade.report.result.ProviderSuccessItemResult; +import cn.daxpay.open.payment.trade.report.result.RefundTrendItemResult; +import cn.daxpay.open.payment.trade.report.result.TradeOverviewResult; +import cn.daxpay.open.payment.trade.report.result.TradeTrendItemResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// # 工作台/分析页交易统计服务 +/// +/// 编排 [TradeReportMapper] 的聚合查询, 处理业务时区(Asia/Shanghai)下的日界线计算、 +/// 趋势/时段/分桶补零、商户排名占比计算、概览环比上一周期对比。 +/// +/// ## 时区 +/// 数据库 `timestamptz` 按 UTC 写入, 这里以 Asia/Shanghai 为业务日界线计算时间区间, +/// 传给 Mapper 做即时比较, 确保接近 CST 日界线的交易归入正确的业务日。 +/// +/// ## 时间区间参数 +/// 所有方法支持 (start, end) 半开区间, 与分析页的 dateRange 直接对应。 +/// overview 额外计算"上一周期"(与本区间等长)用于环比, 前端可直接用 prev* / curr* 计算百分比。 +@Slf4j +@Service +@RequiredArgsConstructor +public class DashboardTradeService { + + /// 业务时区(支付平台默认 Asia/Shanghai) + private static final ZoneId ZONE_CST = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + /// 趋势/分析页天数上下限, 防止前端传参滥用导致大范围全表聚合 + private static final int TREND_DAYS_MIN = 1; + private static final int TREND_DAYS_MAX = 365; + + /// 商户排名最大返回数 + private static final int MERCHANT_RANK_LIMIT_MAX = 50; + private static final int MERCHANT_RANK_LIMIT_DEFAULT = 10; + + /// 金额区间分桶的固定顺序(Service 按此顺序补齐缺失桶) + private static final List AMOUNT_BUCKETS = List.of("0-50", "50-200", "200-1000", "1000-5000", "5000+"); + + private final TradeReportMapper tradeReportMapper; + + // ===== 概览 ===== + + /// 交易概览(今日/昨日) + /// + /// @param date "today" 或 "yesterday", 其它值按 today 处理 + public TradeOverviewResult overview(String date) { + LocalDate today = LocalDate.now(ZONE_CST); + LocalDate target = "yesterday".equalsIgnoreCase(date) ? today.minusDays(1) : today; + // [start, end) 半开区间, end 指向次日 00:00 避免边界重复 + OffsetDateTime start = target.atStartOfDay(ZONE_CST).toOffsetDateTime(); + OffsetDateTime end = target.plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime(); + // 上一周期: 同长度的前一天(单日概览时即昨日, 与 date=yesterday 不同语义, 这里 prev 用于环比) + OffsetDateTime prevStart = start.minusDays(1); + OffsetDateTime prevEnd = start; + return aggregateOverview(start, end, prevStart, prevEnd); + } + + /// 交易概览(自定义区间, 含上期对比) + /// + /// @param start 起始日期(yyyy-MM-dd, 包含), 业务时区 Asia/Shanghai + /// @param end 结束日期(yyyy-MM-dd, **包含**), Service 内部转半开区间 [start, start+1day) + public TradeOverviewResult overview(String start, String end) { + OffsetDateTime startUtc = parseDateStart(start); + OffsetDateTime endUtc = parseDateEndExclusive(end); + // 上一周期: 与本区间等长, 紧接本区间之前 + long daysSpan = java.time.Duration.between(startUtc, endUtc).toDays(); + OffsetDateTime prevStart = startUtc.minusDays(daysSpan); + OffsetDateTime prevEnd = startUtc; + return aggregateOverview(startUtc, endUtc, prevStart, prevEnd); + } + + /// 聚合概览(本/上期 6 次查询组装到一个 Result) + private TradeOverviewResult aggregateOverview( + OffsetDateTime start, OffsetDateTime end, + OffsetDateTime prevStart, OffsetDateTime prevEnd) { + // 本期 + TradeOverviewResult currSuccess = tradeReportMapper.successAggregate(start, end); + TradeOverviewResult currTotal = tradeReportMapper.totalOrdersAggregate(start, end); + TradeOverviewResult currRefund = tradeReportMapper.refundAggregate(start, end); + // 上期(用于环比) + TradeOverviewResult prevSuccess = tradeReportMapper.successAggregate(prevStart, prevEnd); + TradeOverviewResult prevTotal = tradeReportMapper.totalOrdersAggregate(prevStart, prevEnd); + TradeOverviewResult prevRefund = tradeReportMapper.refundAggregate(prevStart, prevEnd); + + return new TradeOverviewResult() + .setSuccessAmount(currSuccess.getSuccessAmount()) + .setSuccessCount(currSuccess.getSuccessCount()) + .setRefundAmount(currRefund.getRefundAmount()) + .setRefundCount(currRefund.getRefundCount()) + .setTotalOrders(currTotal.getTotalOrders()) + .setPrevSuccessAmount(prevSuccess.getSuccessAmount()) + .setPrevSuccessCount(prevSuccess.getSuccessCount()) + .setPrevRefundAmount(prevRefund.getRefundAmount()) + .setPrevRefundCount(prevRefund.getRefundCount()) + .setPrevTotalOrders(prevTotal.getTotalOrders()); + } + + // ===== 趋势 ===== + + /// 交易趋势(指定天数, 近 N 天每日成交金额与笔数) + /// + /// @param days 近 N 天(含今天), 钳制到 [1, 365] + public List trend(int days) { + int safeDays = clampDays(days); + LocalDate today = LocalDate.now(ZONE_CST); + LocalDate startDate = today.minusDays(safeDays - 1L); + OffsetDateTime start = startDate.atStartOfDay(ZONE_CST).toOffsetDateTime(); + OffsetDateTime end = today.plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime(); + return fillTrendDays(startDate, safeDays, tradeReportMapper.trend(start, end)); + } + + /// 交易趋势(自定义区间) + public List trend(String startStr, String endStr) { + OffsetDateTime start = parseDateStart(startStr); + OffsetDateTime end = parseDateEndExclusive(endStr); + long days = java.time.Duration.between(start, end).toDays(); + int safeDays = clampDays((int) days); + LocalDate startDate = start.atZoneSameInstant(ZONE_CST).toLocalDate(); + List rows = tradeReportMapper.trend(start, end); + return fillTrendDays(startDate, safeDays, rows); + } + + /// 退款趋势(指定天数) + public List refundTrend(int days) { + int safeDays = clampDays(days); + LocalDate today = LocalDate.now(ZONE_CST); + LocalDate startDate = today.minusDays(safeDays - 1L); + OffsetDateTime start = startDate.atStartOfDay(ZONE_CST).toOffsetDateTime(); + OffsetDateTime end = today.plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime(); + return fillRefundTrendDays(startDate, safeDays, tradeReportMapper.refundTrend(start, end)); + } + + /// 退款趋势(自定义区间) + public List refundTrend(String startStr, String endStr) { + OffsetDateTime start = parseDateStart(startStr); + OffsetDateTime end = parseDateEndExclusive(endStr); + long days = java.time.Duration.between(start, end).toDays(); + int safeDays = clampDays((int) days); + LocalDate startDate = start.atZoneSameInstant(ZONE_CST).toLocalDate(); + return fillRefundTrendDays(startDate, safeDays, tradeReportMapper.refundTrend(start, end)); + } + + // ===== 渠道分布 ===== + + /// 支付渠道分布(指定天数, 各支付渠道成交金额与笔数) + public List providerDist(int days) { + OffsetDateTime[] range = daysRange(days); + return tradeReportMapper.providerDist(range[0], range[1]); + } + + /// 支付渠道分布(自定义区间) + public List providerDist(String startStr, String endStr) { + return tradeReportMapper.providerDist(parseDateStart(startStr), parseDateEndExclusive(endStr)); + } + + /// 支付渠道成功率(指定天数) + public List providerSuccess(int days) { + OffsetDateTime[] range = daysRange(days); + return tradeReportMapper.providerSuccess(range[0], range[1]); + } + + /// 支付渠道成功率(自定义区间) + public List providerSuccess(String startStr, String endStr) { + return tradeReportMapper.providerSuccess(parseDateStart(startStr), parseDateEndExclusive(endStr)); + } + + // ===== 时段分布 ===== + + /// 24 小时时段分布(指定天数), 补齐 0-23 缺失小时 + public List hourlyDist(int days) { + OffsetDateTime[] range = daysRange(days); + return fillHourlyDist(tradeReportMapper.hourlyDist(range[0], range[1])); + } + + /// 24 小时时段分布(自定义区间) + public List hourlyDist(String startStr, String endStr) { + return fillHourlyDist(tradeReportMapper.hourlyDist(parseDateStart(startStr), parseDateEndExclusive(endStr))); + } + + // ===== 金额区间分桶 ===== + + /// 金额区间分桶(指定天数), 补齐 5 个缺失桶 + public List amountRange(int days) { + OffsetDateTime[] range = daysRange(days); + return fillAmountRange(tradeReportMapper.amountRange(range[0], range[1])); + } + + /// 金额区间分桶(自定义区间) + public List amountRange(String startStr, String endStr) { + return fillAmountRange(tradeReportMapper.amountRange(parseDateStart(startStr), parseDateEndExclusive(endStr))); + } + + // ===== 商户排名 ===== + + /// 商户交易额排名(指定天数, 含占比计算) + /// + /// @param days 近 N 天(含今天) + /// @param limit 返回前 N 名(默认 10, 上限 50) + public List merchantRank(int days, int limit) { + OffsetDateTime[] range = daysRange(days); + return computeProportion(tradeReportMapper.merchantRank(range[0], range[1], clampLimit(limit))); + } + + /// 商户交易额排名(自定义区间) + public List merchantRank(String startStr, String endStr, int limit) { + return computeProportion(tradeReportMapper.merchantRank( + parseDateStart(startStr), parseDateEndExclusive(endStr), clampLimit(limit))); + } + + // ===== 内部工具 ===== + + /// 钳制天数到 [1, 365] + private int clampDays(int days) { + return Math.max(TREND_DAYS_MIN, Math.min(days, TREND_DAYS_MAX)); + } + + /// 钳制商户排名 limit 到 [1, 50], 默认 10 + private int clampLimit(int limit) { + if (limit <= 0) { + return MERCHANT_RANK_LIMIT_DEFAULT; + } + return Math.min(limit, MERCHANT_RANK_LIMIT_MAX); + } + + /// 近 N 天(含今天)的 [start, end) 区间 + private OffsetDateTime[] daysRange(int days) { + int safeDays = clampDays(days); + LocalDate today = LocalDate.now(ZONE_CST); + OffsetDateTime start = today.minusDays(safeDays - 1L).atStartOfDay(ZONE_CST).toOffsetDateTime(); + OffsetDateTime end = today.plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime(); + return new OffsetDateTime[]{start, end}; + } + + /// 解析 yyyy-MM-dd 为该日 00:00:00 Asia/Shanghai 的 OffsetDateTime + private OffsetDateTime parseDateStart(String date) { + return LocalDate.parse(date, DATE_FMT).atStartOfDay(ZONE_CST).toOffsetDateTime(); + } + + /// 解析 yyyy-MM-dd 为次日 00:00:00 Asia/Shanghai 的 OffsetDateTime (半开区间 end) + private OffsetDateTime parseDateEndExclusive(String date) { + return LocalDate.parse(date, DATE_FMT).plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime(); + } + + /// 趋势补零: 对完整日期区间补齐缺失日(amount=0, count=0) + private List fillTrendDays(LocalDate startDate, int days, List rows) { + Map indexed = new HashMap<>(rows.size()); + for (TradeTrendItemResult row : rows) { + indexed.put(row.getDate(), row); + } + List result = new ArrayList<>(days); + for (int i = 0; i < days; i++) { + String key = startDate.plusDays(i).format(DATE_FMT); + TradeTrendItemResult row = indexed.get(key); + if (row != null) { + if (row.getCount() == null) { + row.setCount(0L); + } + result.add(row); + } else { + result.add(new TradeTrendItemResult().setDate(key).setAmount(0L).setCount(0L)); + } + } + return result; + } + + /// 退款趋势补零 + private List fillRefundTrendDays(LocalDate startDate, int days, List rows) { + Map indexed = new HashMap<>(rows.size()); + for (RefundTrendItemResult row : rows) { + indexed.put(row.getDate(), row); + } + List result = new ArrayList<>(days); + for (int i = 0; i < days; i++) { + String key = startDate.plusDays(i).format(DATE_FMT); + RefundTrendItemResult row = indexed.get(key); + if (row != null) { + if (row.getCount() == null) { + row.setCount(0L); + } + result.add(row); + } else { + result.add(new RefundTrendItemResult().setDate(key).setAmount(0L).setCount(0L)); + } + } + return result; + } + + /// 时段分布补齐 0-23 缺失小时 + private List fillHourlyDist(List rows) { + Map indexed = new HashMap<>(rows.size()); + for (HourlyDistItemResult row : rows) { + indexed.put(row.getHour(), row); + } + List result = new ArrayList<>(24); + for (int h = 0; h < 24; h++) { + HourlyDistItemResult row = indexed.get(h); + if (row != null) { + result.add(row); + } else { + result.add(new HourlyDistItemResult().setHour(h).setAmount(0L).setCount(0L)); + } + } + return result; + } + + /// 金额区间补齐 5 个缺失桶(保持固定顺序) + private List fillAmountRange(List rows) { + Map indexed = new HashMap<>(rows.size()); + for (AmountRangeItemResult row : rows) { + indexed.put(row.getBucket(), row.getCount()); + } + List result = new ArrayList<>(AMOUNT_BUCKETS.size()); + for (String bucket : AMOUNT_BUCKETS) { + result.add(new AmountRangeItemResult() + .setBucket(bucket) + .setCount(indexed.getOrDefault(bucket, 0L))); + } + return result; + } + + /// 商户排名占比计算(基于本期返回的 amount 总和, 非全平台总额; 取 top N 内部相对占比) + /// 注意: 这只是 top N 内部的相对占比, 分母为 top N 总额, 不是全平台总额 + private List computeProportion(List rows) { + long total = rows.stream().mapToLong(MerchantRankItemResult::getAmount).sum(); + for (MerchantRankItemResult row : rows) { + double proportion = total > 0 + ? Math.round(row.getAmount() * 1000.0 / total) / 10.0 + : 0.0; + row.setProportion(proportion); + } + return rows; + } +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/order/entity/PayTrade.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/order/entity/PayTrade.java index 9184d9eda..73dd72881 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/order/entity/PayTrade.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/order/entity/PayTrade.java @@ -17,9 +17,11 @@ import java.time.OffsetDateTime; /// 统一资金交易表,记录每一笔资金动作(普通支付/预授权冻结/预授权捕获/周期代扣/合单子单) /// 与容器层(业务单/协议)分离,通过 trade_type + 容器关联字段建立联系。 /// 保留资金动作固有属性、通道反查命脉字段,以及 **轻量组织冗余** -///(source / channelMchNo / storeNo),便于资金列表与汇总免 JOIN 容器; +///(source / channelMchNo / storeNo / provider),便于资金列表与汇总免 JOIN 容器; /// 完整业务上下文/路由细节/payBody/回执仍以容器为准。 /// 过期时间只在容器,本表不存 expiredTime。 +/// 注意: 本表不冗余 channel(支付通道), 通道反查用 channelMchNo; +/// 渠道分布报表/资金列表筛选走 provider(支付渠道)。 @Data @EqualsAndHashCode(callSuper = true) @Accessors(chain = true) @@ -69,6 +71,10 @@ public class PayTrade extends MchBaseEntity { /// 通道商户号(冗余自业务容器, 路由确定后写入; 权威在容器 channelMchNo) private String channelMchNo; + /// 支付渠道(冗余自业务容器, 支付成功sync后回填; 权威在容器 provider) + /// @see cn.daxpay.open.platform.core.enums.pay.channel.PayProviderEnum + private String provider; + /// 门店号(冗余自业务容器, 可空; 权威在容器 storeNo) private String storeNo; diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/dao/TradeReportMapper.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/dao/TradeReportMapper.java new file mode 100644 index 000000000..4c3ca4b6b --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/dao/TradeReportMapper.java @@ -0,0 +1,193 @@ +package cn.daxpay.open.payment.trade.report.dao; + +import cn.daxpay.open.payment.trade.order.entity.PayTrade; +import cn.daxpay.open.payment.trade.report.result.AmountRangeItemResult; +import cn.daxpay.open.payment.trade.report.result.HourlyDistItemResult; +import cn.daxpay.open.payment.trade.report.result.MerchantRankItemResult; +import cn.daxpay.open.payment.trade.report.result.ProviderDistItemResult; +import cn.daxpay.open.payment.trade.report.result.ProviderSuccessItemResult; +import cn.daxpay.open.payment.trade.report.result.RefundTrendItemResult; +import cn.daxpay.open.payment.trade.report.result.TradeOverviewResult; +import cn.daxpay.open.payment.trade.report.result.TradeTrendItemResult; +import com.github.yulichang.base.MPJBaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.OffsetDateTime; +import java.util.List; + +/// # 交易聚合统计 Mapper +/// +/// 工作台仪表盘 + 分析页专用聚合查询, 直查 [PayTrade] 所在的 `pay_trade` 与 `pay_refund_order` 表, +/// 商户排名 JOIN `mch_info` 取商户名。 +/// +/// ## 成交口径 +/// 以 `pay_trade` 资金凭证为准, 资金态 SUCCESS 且 `posted_amount > 0` 才计入成交; +/// `posted_amount` 对结算类动作(normal/gateway/capture 等)= 金额, 对预授权冻结恒为 0, +/// 以此自动排除冻结类资金动作(见 [PayTrade] 字段说明)。 +/// +/// ## 时间字段口径差异(重要) +/// - **成交相关**(`success_amount/success_count`, 退款金额趋势等): 按 `pay_time` 过滤 +/// (支付成功时间), 失败/关闭单 pay_time 为 NULL, 自然落在本区间外。 +/// - **总下单笔数**(`total_orders`, 用于成功率分母): 按 `create_time` 过滤(创建时间), +/// 因为失败/关闭单也需计入分母。两者口径不同, 同一笔单的 create_time 与 pay_time +/// 可能跨区间, 因此 `success_rate = success_count / total_orders` 仅作运营参考。 +/// +/// ## 时区处理 +/// 数据库时间列为 `timestamptz`, MetaObjectHandler 按 UTC 写入。 +/// 概览/渠道分布用 `pay_time BETWEEN` 做瞬时范围比较(OffsetDateTime 即时比较, 无歧义); +/// 趋势/时段/退款趋势按业务时区(Asia/Shanghai)日或小时分组, 用 +/// `pay_time AT TIME ZONE 'Asia/Shanghai'` 转换后截取, 确保接近 CST 日界线的交易归入正确业务日。 +/// +/// ## 金额单位 +/// 所有金额字段为分(最小货币单位), SUM 结果 `::bigint` 强转确保映射为 Java Long。 +@Mapper +public interface TradeReportMapper extends MPJBaseMapper { + + // ===== 概览 ===== + + /// 成交聚合: 按 pay_time 过滤, 填充 success_amount / success_count + @Select("SELECT COALESCE(SUM(posted_amount), 0)::bigint AS success_amount, " + + "COUNT(*)::bigint AS success_count " + + "FROM pay_trade " + + "WHERE status = 'success' AND posted_amount > 0 " + + "AND pay_time >= #{start} AND pay_time < #{end}") + TradeOverviewResult successAggregate( + @Param("start") OffsetDateTime start, + @Param("end") OffsetDateTime end); + + /// 总下单笔数聚合: 按 create_time 过滤, 填充 total_orders(用于成功率分母) + /// 注意口径与 success_count 不同(见类注释"时间字段口径差异") + @Select("SELECT COUNT(*)::bigint AS total_orders " + + "FROM pay_trade " + + "WHERE create_time >= #{start} AND create_time < #{end}") + TradeOverviewResult totalOrdersAggregate( + @Param("start") OffsetDateTime start, + @Param("end") OffsetDateTime end); + + /// 退款聚合: pay_refund_order 成功, 按 finish_time 范围, 填充 refund_amount / refund_count + @Select("SELECT COALESCE(SUM(amount), 0)::bigint AS refund_amount, " + + "COUNT(*)::bigint AS refund_count " + + "FROM pay_refund_order " + + "WHERE status = 'success' " + + "AND finish_time >= #{start} AND finish_time < #{end}") + TradeOverviewResult refundAggregate( + @Param("start") OffsetDateTime start, + @Param("end") OffsetDateTime end); + + // ===== 趋势 ===== + + /// 成交趋势: 按 CST 业务日分组, 返回每日成交金额(分)与笔数, 仅含有成交的日期(无成交日由 Service 补零) + @Select("SELECT TO_CHAR(pay_time AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, " + + "COALESCE(SUM(posted_amount), 0)::bigint AS amount, " + + "COUNT(*)::bigint AS count " + + "FROM pay_trade " + + "WHERE status = 'success' AND posted_amount > 0 " + + "AND pay_time >= #{start} AND pay_time < #{end} " + + "GROUP BY 1 ORDER BY 1") + List trend( + @Param("start") OffsetDateTime start, + @Param("end") OffsetDateTime end); + + /// 退款趋势: 按 CST 业务日分组, 返回每日退款金额(分)与笔数, 仅含有退款的日期(Service 补零) + @Select("SELECT TO_CHAR(finish_time AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, " + + "COALESCE(SUM(amount), 0)::bigint AS amount, " + + "COUNT(*)::bigint AS count " + + "FROM pay_refund_order " + + "WHERE status = 'success' " + + "AND finish_time >= #{start} AND finish_time < #{end} " + + "GROUP BY 1 ORDER BY 1") + List refundTrend( + @Param("start") OffsetDateTime start, + @Param("end") OffsetDateTime end); + + // ===== 渠道分布 ===== + + /// 支付渠道分布: 按支付渠道(provider code)分组, 返回各渠道成交金额(分)与笔数, 按金额倒序 + /// provider 为支付渠道编码(如 wechat/alipay/union_pay), 取自资金凭证冗余字段(权威在容器 provider) + @Select("SELECT provider, " + + "COALESCE(SUM(posted_amount), 0)::bigint AS amount, " + + "COUNT(*)::bigint AS count " + + "FROM pay_trade " + + "WHERE status = 'success' AND posted_amount > 0 " + + "AND provider IS NOT NULL " + + "AND pay_time >= #{start} AND pay_time < #{end} " + + "GROUP BY provider ORDER BY amount DESC") + List providerDist( + @Param("start") OffsetDateTime start, + @Param("end") OffsetDateTime end); + + /// 支付渠道成功率: 按 provider 分组, 成功率 = success_count / total_count * 100 + /// total_count 含所有非初始化态(success/fail/close/cancel)的单, 按 create_time 过滤(与 totalOrders 同口径) + /// NULLIF 防止除零, 结果四舍五入保留 1 位小数 + @Select("SELECT provider, " + + "ROUND(COUNT(*) FILTER (WHERE status = 'success' AND posted_amount > 0)::numeric " + + " / NULLIF(COUNT(*), 0) * 100, 1) AS rate " + + "FROM pay_trade " + + "WHERE provider IS NOT NULL " + + "AND create_time >= #{start} AND create_time < #{end} " + + "GROUP BY provider " + + "HAVING COUNT(*) > 0 " + + "ORDER BY rate DESC") + List providerSuccess( + @Param("start") OffsetDateTime start, + @Param("end") OffsetDateTime end); + + // ===== 时段分布 ===== + + /// 24 小时时段分布: 按 CST 小时(0-23)分组, 返回各时段成交金额(分)与笔数 + /// 仅含有成交的小时(Service 补齐 0-23 缺失时段) + @Select("SELECT EXTRACT(HOUR FROM pay_time AT TIME ZONE 'Asia/Shanghai')::int AS hour, " + + "COALESCE(SUM(posted_amount), 0)::bigint AS amount, " + + "COUNT(*)::bigint AS count " + + "FROM pay_trade " + + "WHERE status = 'success' AND posted_amount > 0 " + + "AND pay_time >= #{start} AND pay_time < #{end} " + + "GROUP BY 1 ORDER BY 1") + List hourlyDist( + @Param("start") OffsetDateTime start, + @Param("end") OffsetDateTime end); + + // ===== 金额区间分桶 ===== + + /// 金额区间分桶: 按 amount(订单金额, 分) CASE 分桶, 桶口径与前端 analytics 一致 + /// 桶定义: 0-50 / 50-200 / 200-1000 / 1000-5000 / 5000+ (单位: 元, 即 0-5000 / 5000-20000 / ... 分) + /// 仅含成功单(status='success' AND posted_amount>0), 按 pay_time 过滤 + /// bucket 字段用固定 key 返回(如 '0-50'), Service 负责补齐 5 个缺失桶 + @Select("SELECT CASE " + + "WHEN amount < 5000 THEN '0-50' " + + "WHEN amount < 20000 THEN '50-200' " + + "WHEN amount < 100000 THEN '200-1000' " + + "WHEN amount < 500000 THEN '1000-5000' " + + "ELSE '5000+' END AS bucket, " + + "COUNT(*)::bigint AS count " + + "FROM pay_trade " + + "WHERE status = 'success' AND posted_amount > 0 " + + "AND pay_time >= #{start} AND pay_time < #{end} " + + "GROUP BY 1") + List amountRange( + @Param("start") OffsetDateTime start, + @Param("end") OffsetDateTime end); + + // ===== 商户排名 ===== + + /// 商户交易额排名: 按 mch_no 分组, JOIN mch_info 取商户名, 按成交金额倒序 + /// 仅含成功单(status='success' AND posted_amount>0), 按 pay_time 过滤 + /// proportion(占比)由 Service 计算(基于本期总成交额) + /// @param limit 返回前 N 名(通常 10) + @Select("SELECT t.mch_no AS mchNo, m.mch_name AS merchantName, " + + "COALESCE(SUM(t.posted_amount), 0)::bigint AS amount, " + + "COUNT(*)::bigint AS orders " + + "FROM pay_trade t " + + "LEFT JOIN mch_info m ON m.mch_no = t.mch_no AND m.deleted = false " + + "WHERE t.status = 'success' AND t.posted_amount > 0 " + + "AND t.pay_time >= #{start} AND t.pay_time < #{end} " + + "GROUP BY t.mch_no, m.mch_name " + + "ORDER BY amount DESC " + + "LIMIT #{limit}") + List merchantRank( + @Param("start") OffsetDateTime start, + @Param("end") OffsetDateTime end, + @Param("limit") int limit); +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/AmountRangeItemResult.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/AmountRangeItemResult.java new file mode 100644 index 000000000..42d92c461 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/AmountRangeItemResult.java @@ -0,0 +1,23 @@ +package cn.daxpay.open.payment.trade.report.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +/// # 金额区间分桶单项结果 +/// +/// 成交订单按金额区间分桶统计笔数, 金额单位分, 桶口径见 Mapper SQL 的 CASE WHEN. +/// Service 层负责补齐所有预定义桶, 保证前端柱图完整。 +@Data +@Accessors(chain = true) +@Schema(title = "金额区间分桶单项") +public class AmountRangeItemResult { + + /// 区间标签(如 '0-50', '5000+') + @Schema(description = "区间标签") + private String bucket; + + /// 笔数 + @Schema(description = "笔数") + private Long count; +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/HourlyDistItemResult.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/HourlyDistItemResult.java new file mode 100644 index 000000000..93315fcf2 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/HourlyDistItemResult.java @@ -0,0 +1,27 @@ +package cn.daxpay.open.payment.trade.report.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +/// # 时段分布单项结果 +/// +/// 一小时区间内的成交金额与笔数(按业务时区 Asia/Shanghai 取 HOUR). +/// Service 层负责补齐 0-23 缺失小时, 保证前端柱图完整。 +@Data +@Accessors(chain = true) +@Schema(title = "时段分布单项") +public class HourlyDistItemResult { + + /// 小时(0-23) + @Schema(description = "小时(0-23)") + private Integer hour; + + /// 成交金额(分) + @Schema(description = "成交金额(分)") + private Long amount; + + /// 成交笔数 + @Schema(description = "成交笔数") + private Long count; +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/MerchantRankItemResult.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/MerchantRankItemResult.java new file mode 100644 index 000000000..2d114e877 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/MerchantRankItemResult.java @@ -0,0 +1,35 @@ +package cn.daxpay.open.payment.trade.report.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +/// # 商户交易额排名单项结果 +/// +/// 按商户维度聚合的成交金额排名, 金额单位分, 商户名取自 mch_base.name(JOIN). +/// proportion(占比百分比) 由 Service 层按总额计算, 保留 1 位小数。 +@Data +@Accessors(chain = true) +@Schema(title = "商户交易额排名单项") +public class MerchantRankItemResult { + + /// 商户号 + @Schema(description = "商户号") + private String mchNo; + + /// 商户名称(JOIN mch_base.name) + @Schema(description = "商户名称") + private String merchantName; + + /// 成交金额(分) + @Schema(description = "成交金额(分)") + private Long amount; + + /// 成交笔数 + @Schema(description = "成交笔数") + private Long orders; + + /// 占比百分比(0-100, 保留 1 位小数) + @Schema(description = "占比百分比") + private Double proportion; +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/ProviderDistItemResult.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/ProviderDistItemResult.java new file mode 100644 index 000000000..80d87de7b --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/ProviderDistItemResult.java @@ -0,0 +1,27 @@ +package cn.daxpay.open.payment.trade.report.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +/// # 支付渠道分布单项结果 +/// +/// 一个支付渠道(provider code)对应的成交金额与笔数, 前端按金额计算占比绘制饼图。 +/// provider 为支付渠道编码(如 wechat/alipay/union_pay), 前端通过渠道 i18n 映射展示名称。 +@Data +@Accessors(chain = true) +@Schema(title = "支付渠道分布单项") +public class ProviderDistItemResult { + + /// 支付渠道编码 + @Schema(description = "支付渠道编码") + private String provider; + + /// 成交金额(分) + @Schema(description = "成交金额(分)") + private Long amount; + + /// 成交笔数 + @Schema(description = "成交笔数") + private Long count; +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/ProviderSuccessItemResult.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/ProviderSuccessItemResult.java new file mode 100644 index 000000000..d3986e64b --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/ProviderSuccessItemResult.java @@ -0,0 +1,23 @@ +package cn.daxpay.open.payment.trade.report.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +/// # 支付渠道成功率单项结果 +/// +/// 一个支付渠道(provider code)对应的支付成功率(百分比). +/// 口径: success_count / total_count * 100, total_count 含所有非初始化态(success/fail/close/cancel). +@Data +@Accessors(chain = true) +@Schema(title = "支付渠道成功率单项") +public class ProviderSuccessItemResult { + + /// 支付渠道编码 + @Schema(description = "支付渠道编码") + private String provider; + + /// 成功率(0-100, 保留 1 位小数) + @Schema(description = "成功率百分比") + private Double rate; +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/RefundTrendItemResult.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/RefundTrendItemResult.java new file mode 100644 index 000000000..d913b9400 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/RefundTrendItemResult.java @@ -0,0 +1,27 @@ +package cn.daxpay.open.payment.trade.report.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +/// # 退款趋势单项结果 +/// +/// 一个日期点对应的退款金额与笔数, 日期格式 yyyy-MM-dd, 金额单位为分。 +/// Service 层负责对查询区间内无退款的日期补零。 +@Data +@Accessors(chain = true) +@Schema(title = "退款趋势单项") +public class RefundTrendItemResult { + + /// 日期(yyyy-MM-dd) + @Schema(description = "日期(yyyy-MM-dd)") + private String date; + + /// 退款金额(分) + @Schema(description = "退款金额(分)") + private Long amount; + + /// 退款笔数 + @Schema(description = "退款笔数") + private Long count; +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/TradeOverviewResult.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/TradeOverviewResult.java new file mode 100644 index 000000000..4480da485 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/TradeOverviewResult.java @@ -0,0 +1,67 @@ +package cn.daxpay.open.payment.trade.report.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +/// # 交易概览统计结果 +/// +/// 成交与退款核心指标聚合, 含总下单笔数(用于成功率) 与上一周期对比字段(用于环比). +/// 金额单位为分(最小货币单位), 前端按需转元展示. +/// +/// ## 字段拆分 +/// - **本期字段**(`success/refund/total*`): 由当前查询区间聚合 +/// - **上期字段**(`prev*`): 由与本区间等长的前一区间聚合, 前端用 +/// `(curr - prev) / prev * 100` 计算环比百分比; 上期无数据(prevCount=0)时前端不应展示环比 +/// +/// 由 [cn.daxpay.open.payment.trade.report.dao.TradeReportMapper] 的成交/退款两个查询分别填充 +/// 各自一半字段, Service 合并. +@Data +@Accessors(chain = true) +@Schema(title = "交易概览统计") +public class TradeOverviewResult { + + // ===== 本期 ===== + + /// 成交金额(分) + @Schema(description = "成交金额(分)") + private Long successAmount; + + /// 成交笔数 + @Schema(description = "成交笔数") + private Long successCount; + + /// 退款金额(分) + @Schema(description = "退款金额(分)") + private Long refundAmount; + + /// 退款笔数 + @Schema(description = "退款笔数") + private Long refundCount; + + /// 总下单笔数(含成功/失败/关闭, 用于计算成功率) + @Schema(description = "总下单笔数") + private Long totalOrders; + + // ===== 上期(用于环比, 任一字段为 null 表示上期无数据) ===== + + /// 上期成交金额(分) + @Schema(description = "上期成交金额(分)") + private Long prevSuccessAmount; + + /// 上期成交笔数 + @Schema(description = "上期成交笔数") + private Long prevSuccessCount; + + /// 上期退款金额(分) + @Schema(description = "上期退款金额(分)") + private Long prevRefundAmount; + + /// 上期退款笔数 + @Schema(description = "上期退款笔数") + private Long prevRefundCount; + + /// 上期总下单笔数 + @Schema(description = "上期总下单笔数") + private Long prevTotalOrders; +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/TradeTrendItemResult.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/TradeTrendItemResult.java new file mode 100644 index 000000000..09dc401da --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/TradeTrendItemResult.java @@ -0,0 +1,27 @@ +package cn.daxpay.open.payment.trade.report.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +/// # 交易趋势单项结果 +/// +/// 一个日期点对应的成交金额与笔数, 日期格式 yyyy-MM-dd, 金额单位为分。 +/// Service 层负责对查询区间内无成交的日期补零, 保证前端折线图连续。 +@Data +@Accessors(chain = true) +@Schema(title = "交易趋势单项") +public class TradeTrendItemResult { + + /// 日期(yyyy-MM-dd) + @Schema(description = "日期(yyyy-MM-dd)") + private String date; + + /// 成交金额(分) + @Schema(description = "成交金额(分)") + private Long amount; + + /// 成交笔数 + @Schema(description = "成交笔数") + private Long count; +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayUniHandleService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayUniHandleService.java index ed3173eab..a198c84aa 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayUniHandleService.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayUniHandleService.java @@ -81,7 +81,7 @@ public class PayUniHandleService { if (order != null) { order.setStatus(GatewayOrderStatusEnum.PAID.getCode()); order.setPayTime(trade.getPayTime()); - applyGatewaySyncReceipts(order, syncResult); + applyGatewaySyncReceipts(trade, order, syncResult); gatewayPayOrderManager.updateById(order); } payPluginAssistService.paySuccess(trade); @@ -91,7 +91,7 @@ public class PayUniHandleService { if (order != null) { order.setStatus(NormalPayOrderStatusEnum.PAID.getCode()); order.setPayTime(trade.getPayTime()); - applyNormalSyncReceipts(order, syncResult); + applyNormalSyncReceipts(trade, order, syncResult); payNormalOrderManager.updateById(order); } payPluginAssistService.paySuccess(trade); @@ -270,12 +270,14 @@ public class PayUniHandleService { order.setErrorMsg(null); } - private void applyNormalSyncReceipts(NormalPayOrder order, PaySyncResultBo syncResult) { + private void applyNormalSyncReceipts(PayTrade trade, NormalPayOrder order, PaySyncResultBo syncResult) { if (syncResult == null) { return; } if (Objects.nonNull(syncResult.getProvider())) { order.setProvider(syncResult.getProvider().getCode()); + // 冗余至资金凭证, 渠道分布报表/资金列表免 JOIN 容器 + trade.setProvider(syncResult.getProvider().getCode()); } order.setBuyerId(syncResult.getBuyerId()); order.setTradeProduct(syncResult.getTradeProduct()); @@ -285,12 +287,14 @@ public class PayUniHandleService { order.setErrorMsg(null); } - private void applyGatewaySyncReceipts(GatewayPayOrder order, PaySyncResultBo syncResult) { + private void applyGatewaySyncReceipts(PayTrade trade, GatewayPayOrder order, PaySyncResultBo syncResult) { if (syncResult == null) { return; } if (Objects.nonNull(syncResult.getProvider())) { order.setProvider(syncResult.getProvider().getCode()); + // 冗余至资金凭证, 渠道分布报表/资金列表免 JOIN 容器 + trade.setProvider(syncResult.getProvider().getCode()); } order.setBuyerId(syncResult.getBuyerId()); order.setTradeProduct(syncResult.getTradeProduct());