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:
DaxPay Dev
2026-07-19 11:07:40 +08:00
parent 98f39595ab
commit 5ab4ac42d1
14 changed files with 1009 additions and 5 deletions

View File

@@ -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;

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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());