mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-08 22:25:32 +08:00
feat(dashboard): 工作台/分析页交易聚合统计接口(8 维度全量)
- 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 数据回填
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<TradeOverviewResult> 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<List<TradeTrendItemResult>> 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<List<RefundTrendItemResult>> 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<List<ProviderDistItemResult>> 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<List<ProviderSuccessItemResult>> 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<List<HourlyDistItemResult>> 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<List<AmountRangeItemResult>> 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<List<MerchantRankItemResult>> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<TradeTrendItemResult> 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<TradeTrendItemResult> 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<TradeTrendItemResult> rows = tradeReportMapper.trend(start, end);
|
||||
return fillTrendDays(startDate, safeDays, rows);
|
||||
}
|
||||
|
||||
/// 退款趋势(指定天数)
|
||||
public List<RefundTrendItemResult> 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<RefundTrendItemResult> 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<ProviderDistItemResult> providerDist(int days) {
|
||||
OffsetDateTime[] range = daysRange(days);
|
||||
return tradeReportMapper.providerDist(range[0], range[1]);
|
||||
}
|
||||
|
||||
/// 支付渠道分布(自定义区间)
|
||||
public List<ProviderDistItemResult> providerDist(String startStr, String endStr) {
|
||||
return tradeReportMapper.providerDist(parseDateStart(startStr), parseDateEndExclusive(endStr));
|
||||
}
|
||||
|
||||
/// 支付渠道成功率(指定天数)
|
||||
public List<ProviderSuccessItemResult> providerSuccess(int days) {
|
||||
OffsetDateTime[] range = daysRange(days);
|
||||
return tradeReportMapper.providerSuccess(range[0], range[1]);
|
||||
}
|
||||
|
||||
/// 支付渠道成功率(自定义区间)
|
||||
public List<ProviderSuccessItemResult> providerSuccess(String startStr, String endStr) {
|
||||
return tradeReportMapper.providerSuccess(parseDateStart(startStr), parseDateEndExclusive(endStr));
|
||||
}
|
||||
|
||||
// ===== 时段分布 =====
|
||||
|
||||
/// 24 小时时段分布(指定天数), 补齐 0-23 缺失小时
|
||||
public List<HourlyDistItemResult> hourlyDist(int days) {
|
||||
OffsetDateTime[] range = daysRange(days);
|
||||
return fillHourlyDist(tradeReportMapper.hourlyDist(range[0], range[1]));
|
||||
}
|
||||
|
||||
/// 24 小时时段分布(自定义区间)
|
||||
public List<HourlyDistItemResult> hourlyDist(String startStr, String endStr) {
|
||||
return fillHourlyDist(tradeReportMapper.hourlyDist(parseDateStart(startStr), parseDateEndExclusive(endStr)));
|
||||
}
|
||||
|
||||
// ===== 金额区间分桶 =====
|
||||
|
||||
/// 金额区间分桶(指定天数), 补齐 5 个缺失桶
|
||||
public List<AmountRangeItemResult> amountRange(int days) {
|
||||
OffsetDateTime[] range = daysRange(days);
|
||||
return fillAmountRange(tradeReportMapper.amountRange(range[0], range[1]));
|
||||
}
|
||||
|
||||
/// 金额区间分桶(自定义区间)
|
||||
public List<AmountRangeItemResult> amountRange(String startStr, String endStr) {
|
||||
return fillAmountRange(tradeReportMapper.amountRange(parseDateStart(startStr), parseDateEndExclusive(endStr)));
|
||||
}
|
||||
|
||||
// ===== 商户排名 =====
|
||||
|
||||
/// 商户交易额排名(指定天数, 含占比计算)
|
||||
///
|
||||
/// @param days 近 N 天(含今天)
|
||||
/// @param limit 返回前 N 名(默认 10, 上限 50)
|
||||
public List<MerchantRankItemResult> merchantRank(int days, int limit) {
|
||||
OffsetDateTime[] range = daysRange(days);
|
||||
return computeProportion(tradeReportMapper.merchantRank(range[0], range[1], clampLimit(limit)));
|
||||
}
|
||||
|
||||
/// 商户交易额排名(自定义区间)
|
||||
public List<MerchantRankItemResult> 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<TradeTrendItemResult> fillTrendDays(LocalDate startDate, int days, List<TradeTrendItemResult> rows) {
|
||||
Map<String, TradeTrendItemResult> indexed = new HashMap<>(rows.size());
|
||||
for (TradeTrendItemResult row : rows) {
|
||||
indexed.put(row.getDate(), row);
|
||||
}
|
||||
List<TradeTrendItemResult> 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<RefundTrendItemResult> fillRefundTrendDays(LocalDate startDate, int days, List<RefundTrendItemResult> rows) {
|
||||
Map<String, RefundTrendItemResult> indexed = new HashMap<>(rows.size());
|
||||
for (RefundTrendItemResult row : rows) {
|
||||
indexed.put(row.getDate(), row);
|
||||
}
|
||||
List<RefundTrendItemResult> 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<HourlyDistItemResult> fillHourlyDist(List<HourlyDistItemResult> rows) {
|
||||
Map<Integer, HourlyDistItemResult> indexed = new HashMap<>(rows.size());
|
||||
for (HourlyDistItemResult row : rows) {
|
||||
indexed.put(row.getHour(), row);
|
||||
}
|
||||
List<HourlyDistItemResult> 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<AmountRangeItemResult> fillAmountRange(List<AmountRangeItemResult> rows) {
|
||||
Map<String, Long> indexed = new HashMap<>(rows.size());
|
||||
for (AmountRangeItemResult row : rows) {
|
||||
indexed.put(row.getBucket(), row.getCount());
|
||||
}
|
||||
List<AmountRangeItemResult> 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<MerchantRankItemResult> computeProportion(List<MerchantRankItemResult> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<PayTrade> {
|
||||
|
||||
// ===== 概览 =====
|
||||
|
||||
/// 成交聚合: 按 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<TradeTrendItemResult> 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<RefundTrendItemResult> 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<ProviderDistItemResult> 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<ProviderSuccessItemResult> 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<HourlyDistItemResult> 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<AmountRangeItemResult> 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<MerchantRankItemResult> merchantRank(
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end,
|
||||
@Param("limit") int limit);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user