refactor(dashboard): 按运营/商户拆分交易报表 Mapper

运营端 Admin*、商户端 Mch* 各自持有 Mapper/Service,core 仅保留 Result 与 TradeReportSupport;同步商户工作台菜单与资金交易只读接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DaxPay Dev
2026-07-23 10:50:18 +08:00
parent 1ab222f883
commit 00ca747134
16 changed files with 1213 additions and 437 deletions

View File

@@ -186,3 +186,8 @@ INSERT INTO "public"."iam_perm_menu" VALUES (606, 607, 'trade:callback-record',
-- Primary Key structure for table iam_perm_menu
-- ----------------------------
ALTER TABLE "public"."iam_perm_menu" ADD CONSTRAINT "iam_perm_menu_pkey" PRIMARY KEY ("id");
-- 商户端仪表板client_code=merchant
INSERT INTO "public"."iam_perm_menu" VALUES (91001, NULL, NULL, 'merchant', 'Dashboard', 'menu.dashboard', 'lucide:layout-dashboard', 'f', 'f', NULL, '/dashboard', '/workspace', -1, 'f', 'f', 'f', 0, NULL, 0, 'f', 'catalog', NULL, NULL, NULL, NULL, NULL, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
INSERT INTO "public"."iam_perm_menu" VALUES (91002, 91001, 'dashboard:workspace', 'merchant', 'Workspace', 'menu.dashboard.workspace', 'lucide:panels-top-left', 'f', 'f', '/dashboard/workspace/index', '/workspace', NULL, 1, 'f', 'f', 't', 0, 1, 1, 'f', 'menu', NULL, NULL, NULL, NULL, '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
INSERT INTO "public"."iam_perm_menu" VALUES (91003, 91001, 'dashboard:analytics', 'merchant', 'Analytics', 'menu.dashboard.analytics', 'lucide:area-chart', 'f', 'f', '/dashboard/analytics/index', '/analytics', NULL, 2, 'f', 'f', 'f', 0, 1, 1, 'f', 'menu', NULL, NULL, NULL, NULL, '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);

View File

@@ -126,3 +126,24 @@ UPDATE "public"."iam_perm_menu"
SET "component" = '/system/config/mobile-app/detail/MobileAppDetail',
"last_modified_time" = CURRENT_TIMESTAMP
WHERE "id" = 40505;
-- ========== 商户端仪表板菜单client_code=merchant ==========
INSERT INTO "public"."iam_perm_menu" VALUES (91001, NULL, NULL, 'merchant', 'Dashboard', 'menu.dashboard', 'lucide:layout-dashboard', 'f', 'f', NULL, '/dashboard', '/workspace', -1, 'f', 'f', 'f', 0, NULL, 0, 'f', 'catalog', NULL, NULL, NULL, NULL, NULL, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("id") DO NOTHING;
INSERT INTO "public"."iam_perm_menu" VALUES (91002, 91001, 'dashboard:workspace', 'merchant', 'Workspace', 'menu.dashboard.workspace', 'lucide:panels-top-left', 'f', 'f', '/dashboard/workspace/index', '/workspace', NULL, 1, 'f', 'f', 't', 0, 1, 1, 'f', 'menu', NULL, NULL, NULL, NULL, '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("id") DO NOTHING;
INSERT INTO "public"."iam_perm_menu" VALUES (91003, 91001, 'dashboard:analytics', 'merchant', 'Analytics', 'menu.dashboard.analytics', 'lucide:area-chart', 'f', 'f', '/dashboard/analytics/index', '/analytics', NULL, 2, 'f', 'f', 'f', 0, 1, 1, 'f', 'menu', NULL, NULL, NULL, NULL, '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("id") DO NOTHING;
-- 挂到所有 client_code=merchant 的角色(若尚无商户角色则无行;新建商户管理员角色时需勾选仪表板)
INSERT INTO "public"."iam_role_menu" ("id", "role_id", "menu_id")
SELECT ((EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint * 1000 + (row_number() OVER ())::bigint),
r."id",
m.menu_id
FROM "public"."iam_role" r
CROSS JOIN (VALUES (91001::bigint), (91002::bigint), (91003::bigint)) AS m(menu_id)
WHERE r."client_code" = 'merchant'
AND NOT EXISTS (
SELECT 1 FROM "public"."iam_role_menu" rm
WHERE rm."role_id" = r."id" AND rm."menu_id" = m.menu_id
);

View File

@@ -2,7 +2,7 @@ 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.admin.service.dashboard.AdminDashboardTradeService;
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;
@@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/// # 工作台/分析页交易统计(管理)
/// # 工作台/分析页交易统计(运营)
///
/// 面向运营端工作台仪表盘与分析页的聚合统计:
/// 概览(含环比)交易趋势退款趋势支付渠道分布渠道成功率时段分布金额区间商户排名
@@ -39,9 +39,9 @@ import java.util.List;
@RestController
@RequestMapping("/admin/dashboard/trade")
@RequiredArgsConstructor
public class DashboardTradeController {
public class AdminDashboardTradeController {
private final DashboardTradeService dashboardTradeService;
private final AdminDashboardTradeService adminDashboardTradeService;
// ===== 概览 =====
@@ -54,9 +54,9 @@ public class DashboardTradeController {
@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(adminDashboardTradeService.overview(start, end));
}
return Res.ok(dashboardTradeService.overview(date != null ? date : "today"));
return Res.ok(adminDashboardTradeService.overview(date != null ? date : "today"));
}
// ===== 趋势 =====
@@ -68,9 +68,9 @@ public class DashboardTradeController {
@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(adminDashboardTradeService.trend(start, end));
}
return Res.ok(dashboardTradeService.trend(days));
return Res.ok(adminDashboardTradeService.trend(days));
}
@Operation(summary = "退款趋势(指定天数或自定义区间)")
@@ -80,9 +80,9 @@ public class DashboardTradeController {
@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(adminDashboardTradeService.refundTrend(start, end));
}
return Res.ok(dashboardTradeService.refundTrend(days));
return Res.ok(adminDashboardTradeService.refundTrend(days));
}
// ===== 渠道 =====
@@ -94,9 +94,9 @@ public class DashboardTradeController {
@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(adminDashboardTradeService.providerDist(start, end));
}
return Res.ok(dashboardTradeService.providerDist(days));
return Res.ok(adminDashboardTradeService.providerDist(days));
}
@Operation(summary = "支付渠道成功率(指定天数或自定义区间)")
@@ -106,9 +106,9 @@ public class DashboardTradeController {
@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(adminDashboardTradeService.providerSuccess(start, end));
}
return Res.ok(dashboardTradeService.providerSuccess(days));
return Res.ok(adminDashboardTradeService.providerSuccess(days));
}
// ===== 时段 =====
@@ -120,9 +120,9 @@ public class DashboardTradeController {
@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(adminDashboardTradeService.hourlyDist(start, end));
}
return Res.ok(dashboardTradeService.hourlyDist(days));
return Res.ok(adminDashboardTradeService.hourlyDist(days));
}
// ===== 金额区间 =====
@@ -134,9 +134,9 @@ public class DashboardTradeController {
@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(adminDashboardTradeService.amountRange(start, end));
}
return Res.ok(dashboardTradeService.amountRange(days));
return Res.ok(adminDashboardTradeService.amountRange(days));
}
// ===== 商户排名 =====
@@ -149,8 +149,8 @@ public class DashboardTradeController {
@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(adminDashboardTradeService.merchantRank(start, end, limit));
}
return Res.ok(dashboardTradeService.merchantRank(days, limit));
return Res.ok(adminDashboardTradeService.merchantRank(days, limit));
}
}

View File

@@ -1,4 +1,4 @@
package cn.daxpay.open.payment.trade.report.dao;
package cn.daxpay.open.payment.admin.dao.dashboard;
import cn.daxpay.open.payment.trade.order.entity.PayTrade;
import cn.daxpay.open.payment.trade.report.result.AmountRangeItemResult;
@@ -17,33 +17,17 @@ import org.apache.ibatis.annotations.Select;
import java.time.OffsetDateTime;
import java.util.List;
/// # 交易聚合统计 Mapper
/// # 运营端交易聚合统计 Mapper
///
/// 工作台仪表盘 + 分析页专用聚合查询, 直查 [PayTrade] 所在的 `pay_trade` `pay_refund_order` ,
/// 商户排名 JOIN `mch_info` 取商户名
/// 全平台口径, **** `mch_no` 过滤含商户排名; 不含商户端维度排名
///
/// ## 成交口径
/// `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 日界线的交易归入正确业务日
/// `pay_trade` 资金凭证为准, 资金态 SUCCESS `posted_amount > 0` 才计入成交
///
/// ## 金额单位
/// 所有金额字段为分(最小货币单位), SUM 结果 `::bigint` 强转确保映射为 Java Long
@Mapper
public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
public interface AdminTradeReportMapper extends MPJBaseMapper<PayTrade> {
// ===== 概览 =====
@@ -57,8 +41,7 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
@Param("start") OffsetDateTime start,
@Param("end") OffsetDateTime end);
/// 总下单笔数聚合: create_time 过滤, 填充 total_orders(用于成功率分母)
/// 注意口径与 success_count 不同(见类注释"时间字段口径差异")
/// 总下单笔数聚合: create_time 过滤, 填充 total_orders
@Select("SELECT COUNT(*)::bigint AS total_orders "
+ "FROM pay_trade "
+ "WHERE create_time >= #{start} AND create_time < #{end}")
@@ -66,7 +49,7 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
@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 "
@@ -78,7 +61,6 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
// ===== 趋势 =====
/// 成交趋势: 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 "
@@ -90,7 +72,6 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
@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 "
@@ -104,8 +85,6 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
// ===== 渠道分布 =====
/// 支付渠道分布: 按支付渠道(provider code)分组, 返回各渠道成交金额()与笔数, 按金额倒序
/// provider 为支付渠道编码( wechat/alipay/union_pay), 取自资金凭证冗余字段(权威在容器 provider)
@Select("SELECT provider, "
+ "COALESCE(SUM(posted_amount), 0)::bigint AS amount, "
+ "COUNT(*)::bigint AS count "
@@ -118,9 +97,6 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
@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 "
@@ -136,8 +112,6 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
// ===== 时段分布 =====
/// 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 "
@@ -151,10 +125,6 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
// ===== 金额区间分桶 =====
/// 金额区间分桶: 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' "
@@ -172,10 +142,6 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
// ===== 商户排名 =====
/// 商户交易额排名: 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 "

View File

@@ -0,0 +1,182 @@
package cn.daxpay.open.payment.admin.service.dashboard;
import cn.daxpay.open.payment.admin.dao.dashboard.AdminTradeReportMapper;
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 cn.daxpay.open.payment.trade.report.support.TradeReportSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
/// # 工作台/分析页交易统计(运营端)
///
/// 编排 [AdminTradeReportMapper](全平台口径)与 [TradeReportSupport]。
@Service
@RequiredArgsConstructor
public class AdminDashboardTradeService {
private final AdminTradeReportMapper adminTradeReportMapper;
private final TradeReportSupport tradeReportSupport;
// ===== 概览 =====
public TradeOverviewResult overview(String date) {
LocalDate today = LocalDate.now(TradeReportSupport.ZONE_CST);
LocalDate target = "yesterday".equalsIgnoreCase(date) ? today.minusDays(1) : today;
OffsetDateTime start = target.atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
OffsetDateTime end = target.plusDays(1).atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
OffsetDateTime prevStart = start.minusDays(1);
OffsetDateTime prevEnd = start;
return aggregateOverview(start, end, prevStart, prevEnd);
}
public TradeOverviewResult overview(String start, String end) {
OffsetDateTime startUtc = tradeReportSupport.parseDateStart(start);
OffsetDateTime endUtc = tradeReportSupport.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);
}
private TradeOverviewResult aggregateOverview(
OffsetDateTime start, OffsetDateTime end,
OffsetDateTime prevStart, OffsetDateTime prevEnd) {
TradeOverviewResult currSuccess = adminTradeReportMapper.successAggregate(start, end);
TradeOverviewResult currTotal = adminTradeReportMapper.totalOrdersAggregate(start, end);
TradeOverviewResult currRefund = adminTradeReportMapper.refundAggregate(start, end);
TradeOverviewResult prevSuccess = adminTradeReportMapper.successAggregate(prevStart, prevEnd);
TradeOverviewResult prevTotal = adminTradeReportMapper.totalOrdersAggregate(prevStart, prevEnd);
TradeOverviewResult prevRefund = adminTradeReportMapper.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());
}
// ===== 趋势 =====
public List<TradeTrendItemResult> trend(int days) {
int safeDays = tradeReportSupport.clampDays(days);
LocalDate today = LocalDate.now(TradeReportSupport.ZONE_CST);
LocalDate startDate = today.minusDays(safeDays - 1L);
OffsetDateTime start = startDate.atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
OffsetDateTime end = today.plusDays(1).atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
return tradeReportSupport.fillTrendDays(startDate, safeDays, adminTradeReportMapper.trend(start, end));
}
public List<TradeTrendItemResult> trend(String startStr, String endStr) {
OffsetDateTime start = tradeReportSupport.parseDateStart(startStr);
OffsetDateTime end = tradeReportSupport.parseDateEndExclusive(endStr);
long days = java.time.Duration.between(start, end).toDays();
int safeDays = tradeReportSupport.clampDays((int) days);
LocalDate startDate = start.atZoneSameInstant(TradeReportSupport.ZONE_CST).toLocalDate();
return tradeReportSupport.fillTrendDays(startDate, safeDays, adminTradeReportMapper.trend(start, end));
}
public List<RefundTrendItemResult> refundTrend(int days) {
int safeDays = tradeReportSupport.clampDays(days);
LocalDate today = LocalDate.now(TradeReportSupport.ZONE_CST);
LocalDate startDate = today.minusDays(safeDays - 1L);
OffsetDateTime start = startDate.atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
OffsetDateTime end = today.plusDays(1).atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
return tradeReportSupport.fillRefundTrendDays(
startDate, safeDays, adminTradeReportMapper.refundTrend(start, end));
}
public List<RefundTrendItemResult> refundTrend(String startStr, String endStr) {
OffsetDateTime start = tradeReportSupport.parseDateStart(startStr);
OffsetDateTime end = tradeReportSupport.parseDateEndExclusive(endStr);
long days = java.time.Duration.between(start, end).toDays();
int safeDays = tradeReportSupport.clampDays((int) days);
LocalDate startDate = start.atZoneSameInstant(TradeReportSupport.ZONE_CST).toLocalDate();
return tradeReportSupport.fillRefundTrendDays(
startDate, safeDays, adminTradeReportMapper.refundTrend(start, end));
}
// ===== 渠道 =====
public List<ProviderDistItemResult> providerDist(int days) {
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
return adminTradeReportMapper.providerDist(range[0], range[1]);
}
public List<ProviderDistItemResult> providerDist(String startStr, String endStr) {
return adminTradeReportMapper.providerDist(
tradeReportSupport.parseDateStart(startStr),
tradeReportSupport.parseDateEndExclusive(endStr));
}
public List<ProviderSuccessItemResult> providerSuccess(int days) {
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
return adminTradeReportMapper.providerSuccess(range[0], range[1]);
}
public List<ProviderSuccessItemResult> providerSuccess(String startStr, String endStr) {
return adminTradeReportMapper.providerSuccess(
tradeReportSupport.parseDateStart(startStr),
tradeReportSupport.parseDateEndExclusive(endStr));
}
// ===== 时段 / 金额 =====
public List<HourlyDistItemResult> hourlyDist(int days) {
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
int daysSpan = tradeReportSupport.clampDays(days);
return tradeReportSupport.toDailyAverage(
tradeReportSupport.fillHourlyDist(adminTradeReportMapper.hourlyDist(range[0], range[1])),
daysSpan);
}
public List<HourlyDistItemResult> hourlyDist(String startStr, String endStr) {
OffsetDateTime start = tradeReportSupport.parseDateStart(startStr);
OffsetDateTime end = tradeReportSupport.parseDateEndExclusive(endStr);
long daysSpan = Math.max(1L, java.time.Duration.between(start, end).toDays());
return tradeReportSupport.toDailyAverage(
tradeReportSupport.fillHourlyDist(adminTradeReportMapper.hourlyDist(start, end)),
daysSpan);
}
public List<AmountRangeItemResult> amountRange(int days) {
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
return tradeReportSupport.fillAmountRange(adminTradeReportMapper.amountRange(range[0], range[1]));
}
public List<AmountRangeItemResult> amountRange(String startStr, String endStr) {
return tradeReportSupport.fillAmountRange(adminTradeReportMapper.amountRange(
tradeReportSupport.parseDateStart(startStr),
tradeReportSupport.parseDateEndExclusive(endStr)));
}
// ===== 商户排名 =====
public List<MerchantRankItemResult> merchantRank(int days, int limit) {
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
return tradeReportSupport.computeMerchantProportion(
adminTradeReportMapper.merchantRank(range[0], range[1], tradeReportSupport.clampLimit(limit)));
}
public List<MerchantRankItemResult> merchantRank(String startStr, String endStr, int limit) {
return tradeReportSupport.computeMerchantProportion(adminTradeReportMapper.merchantRank(
tradeReportSupport.parseDateStart(startStr),
tradeReportSupport.parseDateEndExclusive(endStr),
tradeReportSupport.clampLimit(limit)));
}
}

View File

@@ -1,374 +0,0 @@
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));
}
// ===== 时段分布 =====
/// 时段分布(日均, 指定天数): 区间内按小时汇总后 ÷ 天数, 补齐 0-23
public List<HourlyDistItemResult> hourlyDist(int days) {
OffsetDateTime[] range = daysRange(days);
int daysSpan = clampDays(days);
return toDailyAverage(fillHourlyDist(tradeReportMapper.hourlyDist(range[0], range[1])), daysSpan);
}
/// 时段分布(日均, 自定义区间): 区间内按小时汇总后 ÷ 天数, 补齐 0-23
public List<HourlyDistItemResult> hourlyDist(String startStr, String endStr) {
OffsetDateTime start = parseDateStart(startStr);
OffsetDateTime end = parseDateEndExclusive(endStr);
long daysSpan = Math.max(1L, java.time.Duration.between(start, end).toDays());
return toDailyAverage(fillHourlyDist(tradeReportMapper.hourlyDist(start, end)), daysSpan);
}
// ===== 金额区间分桶 =====
/// 金额区间分桶(指定天数), 补齐 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 缺失小时(此时仍为区间合计, 日均见 [toDailyAverage])
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) {
if (row.getCount() == null) {
row.setCount(0D);
}
if (row.getAmount() == null) {
row.setAmount(0L);
}
result.add(row);
} else {
result.add(new HourlyDistItemResult().setHour(h).setAmount(0L).setCount(0D));
}
}
return result;
}
/// 将区间内按小时汇总的金额/笔数转为日均(天数至少为 1)
/// 笔数保留 1 位小数; 金额(分)四舍五入为 Long
private List<HourlyDistItemResult> toDailyAverage(List<HourlyDistItemResult> rows, long daysSpan) {
long days = Math.max(1L, daysSpan);
for (HourlyDistItemResult row : rows) {
long amount = row.getAmount() == null ? 0L : row.getAmount();
double count = row.getCount() == null ? 0D : row.getCount();
row.setAmount(Math.round((double) amount / days));
row.setCount(Math.round(count * 10.0 / days) / 10.0);
}
return rows;
}
/// 金额区间补齐 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

@@ -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;
/// # 商户端维度交易额排名单项
///
/// 在本商户下按通道商户 / 应用 / 门店聚合; 金额单位分。
/// proportion 由 Service 按返回列表总额计算。
@Data
@Accessors(chain = true)
@Schema(title = "维度交易额排名单项")
public class DimRankItemResult {
/// 维度键(channelMchNo / appId / storeNo; 未指定门店为空串)
@Schema(description = "维度键")
private String dimKey;
/// 展示名(JOIN 得到; 门店未指定时为 null, 由前端 i18n)
@Schema(description = "展示名")
private String dimName;
/// 成交金额(分)
@Schema(description = "成交金额(分)")
private Long amount;
/// 成交笔数
@Schema(description = "成交笔数")
private Long orders;
/// 占比百分比(0-100, 保留 1 位小数)
@Schema(description = "占比百分比")
private Double proportion;
}

View File

@@ -14,8 +14,8 @@ import lombok.experimental.Accessors;
/// - **上期字段**(`prev*`): 由与本区间等长的前一区间聚合, 前端用
/// `(curr - prev) / prev * 100` 计算环比百分比; 上期无数据(prevCount=0)时前端不应展示环比
///
/// 由 [cn.daxpay.open.payment.trade.report.dao.TradeReportMapper] 的成交/退款两个查询分别填充
/// 各自一半字段, Service 合并.
/// 由运营/商户端各自 TradeReportMapper 的成交/退款/总笔数查询分别填充
/// 各自一半字段, 再由对应 DashboardTradeService 合并.
@Data
@Accessors(chain = true)
@Schema(title = "交易概览统计")

View File

@@ -0,0 +1,190 @@
package cn.daxpay.open.payment.trade.report.support;
import cn.daxpay.open.payment.trade.report.result.AmountRangeItemResult;
import cn.daxpay.open.payment.trade.report.result.DimRankItemResult;
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.RefundTrendItemResult;
import cn.daxpay.open.payment.trade.report.result.TradeTrendItemResult;
import org.springframework.stereotype.Component;
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;
/// # 交易报表共享工具
///
/// 日期区间(CST 日界线)、趋势/时段/分桶补零、排名占比计算。无 Mapper 依赖, 供运营/商户端 Service 共用。
@Component
public class TradeReportSupport {
public static final ZoneId ZONE_CST = ZoneId.of("Asia/Shanghai");
public 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 RANK_LIMIT_MAX = 50;
private static final int RANK_LIMIT_DEFAULT = 10;
private static final List<String> AMOUNT_BUCKETS = List.of("0-50", "50-200", "200-1000", "1000-5000", "5000+");
/// 钳制天数到 [1, 365]
public int clampDays(int days) {
return Math.max(TREND_DAYS_MIN, Math.min(days, TREND_DAYS_MAX));
}
/// 钳制排名条数; `<=0` 时用默认 10, 上限 50
public int clampLimit(int limit) {
if (limit <= 0) {
return RANK_LIMIT_DEFAULT;
}
return Math.min(limit, RANK_LIMIT_MAX);
}
/// 近 N 天(含今天)半开区间 `[start, end)`
public 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 CST
public OffsetDateTime parseDateStart(String date) {
return LocalDate.parse(date, DATE_FMT).atStartOfDay(ZONE_CST).toOffsetDateTime();
}
/// 解析 yyyy-MM-dd 为次日 00:00 CST(半开区间上界)
public OffsetDateTime parseDateEndExclusive(String date) {
return LocalDate.parse(date, DATE_FMT).plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime();
}
/// 交易趋势按日补零
public 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;
}
/// 退款趋势按日补零
public 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
public 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) {
if (row.getCount() == null) {
row.setCount(0D);
}
if (row.getAmount() == null) {
row.setAmount(0L);
}
result.add(row);
} else {
result.add(new HourlyDistItemResult().setHour(h).setAmount(0L).setCount(0D));
}
}
return result;
}
/// 时段汇总转为日均(金额四舍五入到分, 笔数保留一位小数)
public List<HourlyDistItemResult> toDailyAverage(List<HourlyDistItemResult> rows, long daysSpan) {
long days = Math.max(1L, daysSpan);
for (HourlyDistItemResult row : rows) {
long amount = row.getAmount() == null ? 0L : row.getAmount();
double count = row.getCount() == null ? 0D : row.getCount();
row.setAmount(Math.round((double) amount / days));
row.setCount(Math.round(count * 10.0 / days) / 10.0);
}
return rows;
}
/// 金额区间按固定桶序补零
public 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;
}
/// 运营端商户排名占比(一位小数百分比)
public List<MerchantRankItemResult> computeMerchantProportion(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;
}
/// 商户端维度排名占比
public List<DimRankItemResult> computeDimProportion(List<DimRankItemResult> rows) {
long total = rows.stream()
.mapToLong(r -> r.getAmount() == null ? 0L : r.getAmount())
.sum();
for (DimRankItemResult row : rows) {
long amount = row.getAmount() == null ? 0L : row.getAmount();
double proportion = total > 0
? Math.round(amount * 1000.0 / total) / 10.0
: 0.0;
row.setProportion(proportion);
}
return rows;
}
}

View File

@@ -0,0 +1,142 @@
package cn.daxpay.open.payment.merchant.controller.dashboard;
import cn.daxpay.open.platform.core.rest.Res;
import cn.daxpay.open.platform.core.rest.result.Result;
import cn.daxpay.open.payment.merchant.result.dashboard.MchDashboardHeaderCountResult;
import cn.daxpay.open.payment.merchant.service.dashboard.MchDashboardTradeService;
import cn.daxpay.open.payment.trade.report.result.AmountRangeItemResult;
import cn.daxpay.open.payment.trade.report.result.DimRankItemResult;
import cn.daxpay.open.payment.trade.report.result.HourlyDistItemResult;
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;
/// # 工作台/分析页交易统计(商户端)
///
/// 参数约定与运营端 `/admin/dashboard/trade` 对齐; 数据强制按当前登录商户隔离。
@Tag(name = "工作台/分析页交易统计(商户端)")
@Validated
@RestController
@RequestMapping("/mch/dashboard/trade")
@RequiredArgsConstructor
public class MchDashboardTradeController {
private final MchDashboardTradeService mchDashboardTradeService;
@Operation(summary = "工作台头部计数(应用/门店/通道商户)")
@GetMapping("/header-counts")
public Result<MchDashboardHeaderCountResult> headerCounts() {
return Res.ok(mchDashboardTradeService.headerCounts());
}
@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(mchDashboardTradeService.overview(start, end));
}
return Res.ok(mchDashboardTradeService.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(mchDashboardTradeService.trend(start, end));
}
return Res.ok(mchDashboardTradeService.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(mchDashboardTradeService.refundTrend(start, end));
}
return Res.ok(mchDashboardTradeService.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(mchDashboardTradeService.providerDist(start, end));
}
return Res.ok(mchDashboardTradeService.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(mchDashboardTradeService.providerSuccess(start, end));
}
return Res.ok(mchDashboardTradeService.providerSuccess(days));
}
@Operation(summary = "时段分布(日均)")
@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(mchDashboardTradeService.hourlyDist(start, end));
}
return Res.ok(mchDashboardTradeService.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(mchDashboardTradeService.amountRange(start, end));
}
return Res.ok(mchDashboardTradeService.amountRange(days));
}
/// 维度排行: dim=channelMch|app|store
@Operation(summary = "维度交易额排名(通道商户/应用/门店)")
@GetMapping("/dim-rank")
public Result<List<DimRankItemResult>> dimRank(
@RequestParam(defaultValue = "app") String dim,
@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(mchDashboardTradeService.dimRank(dim, start, end, limit));
}
return Res.ok(mchDashboardTradeService.dimRank(dim, days, limit));
}
}

View File

@@ -0,0 +1,35 @@
package cn.daxpay.open.payment.merchant.controller.trade;
import cn.daxpay.open.platform.core.rest.Res;
import cn.daxpay.open.platform.core.rest.param.PageParam;
import cn.daxpay.open.platform.core.rest.result.PageResult;
import cn.daxpay.open.platform.core.rest.result.Result;
import cn.daxpay.open.payment.merchant.service.trade.MchPayTradeService;
import cn.daxpay.open.payment.trade.order.param.PayTradeQuery;
import cn.daxpay.open.payment.trade.order.result.PayTradeResult;
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.RestController;
/// # 资金交易凭证(商户端)
///
/// 工作台「最近资金交易」等只读列表;完整订单管理页后续再挂菜单。
@Validated
@Tag(name = "资金交易凭证(商户端)")
@RestController
@RequestMapping("/mch/order/pay-trade")
@RequiredArgsConstructor
public class MchPayTradeController {
private final MchPayTradeService mchPayTradeService;
@Operation(summary = "资金交易凭证分页")
@GetMapping("/page")
public Result<PageResult<PayTradeResult>> page(PageParam pageParam, PayTradeQuery query) {
return Res.ok(mchPayTradeService.page(pageParam, query));
}
}

View File

@@ -0,0 +1,19 @@
package cn.daxpay.open.payment.merchant.convert.trade;
import cn.daxpay.open.payment.trade.order.entity.PayTrade;
import cn.daxpay.open.payment.trade.order.result.PayTradeResult;
import org.mapstruct.Mapper;
import org.mapstruct.ReportingPolicy;
import org.mapstruct.factory.Mappers;
/// # 资金交易凭证转换器(商户端)
///
/// 列表页同名映射;详情联表由后续订单页再补
@Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface MchPayTradeConvert {
MchPayTradeConvert CONVERT = Mappers.getMapper(MchPayTradeConvert.class);
/// PayTrade → Result工作台最近交易列表
PayTradeResult toResult(PayTrade entity);
}

View File

@@ -0,0 +1,224 @@
package cn.daxpay.open.payment.merchant.dao.dashboard;
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.DimRankItemResult;
import cn.daxpay.open.payment.trade.report.result.HourlyDistItemResult;
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
///
/// 强制 `AND mch_no = #{mchNo}`, 禁止可空全平台语义。含维度排名; 不含运营端商户排名。
///
/// ## 成交口径
/// 以 `pay_trade` 资金凭证为准, 资金态 SUCCESS 且 `posted_amount > 0` 才计入成交。
///
/// ## 金额单位
/// 所有金额字段为分(最小货币单位), SUM 结果 `::bigint` 强转确保映射为 Java Long。
@Mapper
public interface MchTradeReportMapper 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} "
+ "AND mch_no = #{mchNo}")
TradeOverviewResult successAggregate(
@Param("start") OffsetDateTime start,
@Param("end") OffsetDateTime end,
@Param("mchNo") String mchNo);
/// 总下单笔数聚合: 按 create_time 过滤, 填充 total_orders
@Select("SELECT COUNT(*)::bigint AS total_orders "
+ "FROM pay_trade "
+ "WHERE create_time >= #{start} AND create_time < #{end} "
+ "AND mch_no = #{mchNo}")
TradeOverviewResult totalOrdersAggregate(
@Param("start") OffsetDateTime start,
@Param("end") OffsetDateTime end,
@Param("mchNo") String mchNo);
/// 退款聚合
@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} "
+ "AND mch_no = #{mchNo}")
TradeOverviewResult refundAggregate(
@Param("start") OffsetDateTime start,
@Param("end") OffsetDateTime end,
@Param("mchNo") String mchNo);
// ===== 趋势 =====
@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} "
+ "AND mch_no = #{mchNo} "
+ "GROUP BY 1 ORDER BY 1")
List<TradeTrendItemResult> trend(
@Param("start") OffsetDateTime start,
@Param("end") OffsetDateTime end,
@Param("mchNo") String mchNo);
@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} "
+ "AND mch_no = #{mchNo} "
+ "GROUP BY 1 ORDER BY 1")
List<RefundTrendItemResult> refundTrend(
@Param("start") OffsetDateTime start,
@Param("end") OffsetDateTime end,
@Param("mchNo") String mchNo);
// ===== 渠道分布 =====
@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} "
+ "AND mch_no = #{mchNo} "
+ "GROUP BY provider ORDER BY amount DESC")
List<ProviderDistItemResult> providerDist(
@Param("start") OffsetDateTime start,
@Param("end") OffsetDateTime end,
@Param("mchNo") String mchNo);
@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} "
+ "AND mch_no = #{mchNo} "
+ "GROUP BY provider "
+ "HAVING COUNT(*) > 0 "
+ "ORDER BY rate DESC")
List<ProviderSuccessItemResult> providerSuccess(
@Param("start") OffsetDateTime start,
@Param("end") OffsetDateTime end,
@Param("mchNo") String mchNo);
// ===== 时段分布 =====
@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} "
+ "AND mch_no = #{mchNo} "
+ "GROUP BY 1 ORDER BY 1")
List<HourlyDistItemResult> hourlyDist(
@Param("start") OffsetDateTime start,
@Param("end") OffsetDateTime end,
@Param("mchNo") String mchNo);
// ===== 金额区间分桶 =====
@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} "
+ "AND mch_no = #{mchNo} "
+ "GROUP BY 1")
List<AmountRangeItemResult> amountRange(
@Param("start") OffsetDateTime start,
@Param("end") OffsetDateTime end,
@Param("mchNo") String mchNo);
// ===== 维度排名 =====
/// 按通道商户号分组排名
@Select("SELECT t.channel_mch_no AS dimKey, "
+ "COALESCE(cm.channel_merchant_name, t.channel_mch_no) AS dimName, "
+ "COALESCE(SUM(t.posted_amount), 0)::bigint AS amount, "
+ "COUNT(*)::bigint AS orders "
+ "FROM pay_trade t "
+ "LEFT JOIN mch_channel_merchant cm "
+ " ON cm.channel_mch_no = t.channel_mch_no AND cm.mch_no = t.mch_no AND cm.deleted = false "
+ "WHERE t.status = 'success' AND t.posted_amount > 0 "
+ "AND t.channel_mch_no IS NOT NULL AND t.channel_mch_no <> '' "
+ "AND t.pay_time >= #{start} AND t.pay_time < #{end} "
+ "AND t.mch_no = #{mchNo} "
+ "GROUP BY t.channel_mch_no, cm.channel_merchant_name "
+ "ORDER BY amount DESC "
+ "LIMIT #{limit}")
List<DimRankItemResult> dimRankByChannelMch(
@Param("start") OffsetDateTime start,
@Param("end") OffsetDateTime end,
@Param("mchNo") String mchNo,
@Param("limit") int limit);
/// 按应用分组排名
@Select("SELECT t.app_id AS dimKey, "
+ "COALESCE(a.app_name, t.app_id) AS dimName, "
+ "COALESCE(SUM(t.posted_amount), 0)::bigint AS amount, "
+ "COUNT(*)::bigint AS orders "
+ "FROM pay_trade t "
+ "LEFT JOIN mch_app_info a "
+ " ON a.app_id = t.app_id AND a.mch_no = t.mch_no AND a.deleted = false "
+ "WHERE t.status = 'success' AND t.posted_amount > 0 "
+ "AND t.app_id IS NOT NULL AND t.app_id <> '' "
+ "AND t.pay_time >= #{start} AND t.pay_time < #{end} "
+ "AND t.mch_no = #{mchNo} "
+ "GROUP BY t.app_id, a.app_name "
+ "ORDER BY amount DESC "
+ "LIMIT #{limit}")
List<DimRankItemResult> dimRankByApp(
@Param("start") OffsetDateTime start,
@Param("end") OffsetDateTime end,
@Param("mchNo") String mchNo,
@Param("limit") int limit);
/// 按门店分组排名; 空 store_no 归到 dimKey='' (前端展示「未指定门店」)
@Select("SELECT COALESCE(NULLIF(t.store_no, ''), '') AS dimKey, "
+ "s.store_name AS dimName, "
+ "COALESCE(SUM(t.posted_amount), 0)::bigint AS amount, "
+ "COUNT(*)::bigint AS orders "
+ "FROM pay_trade t "
+ "LEFT JOIN mch_store_info s "
+ " ON s.store_no = t.store_no AND s.mch_no = t.mch_no AND s.deleted = false "
+ "WHERE t.status = 'success' AND t.posted_amount > 0 "
+ "AND t.pay_time >= #{start} AND t.pay_time < #{end} "
+ "AND t.mch_no = #{mchNo} "
+ "GROUP BY COALESCE(NULLIF(t.store_no, ''), ''), s.store_name "
+ "ORDER BY amount DESC "
+ "LIMIT #{limit}")
List<DimRankItemResult> dimRankByStore(
@Param("start") OffsetDateTime start,
@Param("end") OffsetDateTime end,
@Param("mchNo") String mchNo,
@Param("limit") int limit);
}

View File

@@ -0,0 +1,24 @@
package cn.daxpay.open.payment.merchant.result.dashboard;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 商户工作台头部计数
@Data
@Accessors(chain = true)
@Schema(title = "商户工作台头部计数")
public class MchDashboardHeaderCountResult {
/// 应用数
@Schema(description = "应用数")
private Long appCount;
/// 门店数
@Schema(description = "门店数")
private Long storeCount;
/// 通道商户数
@Schema(description = "通道商户数")
private Long channelMerchantCount;
}

View File

@@ -0,0 +1,265 @@
package cn.daxpay.open.payment.merchant.service.dashboard;
import cn.daxpay.open.platform.core.code.CommonCode;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.payment.common.context.PaymentContext;
import cn.daxpay.open.payment.merchant.dao.appinfo.MchAppInfoManager;
import cn.daxpay.open.payment.merchant.dao.channel.ChannelMerchantManager;
import cn.daxpay.open.payment.merchant.dao.dashboard.MchTradeReportMapper;
import cn.daxpay.open.payment.merchant.dao.store.MchStoreInfoManager;
import cn.daxpay.open.payment.merchant.entity.appinfo.MchAppInfo;
import cn.daxpay.open.payment.merchant.entity.channel.ChannelMerchant;
import cn.daxpay.open.payment.merchant.entity.store.MchStoreInfo;
import cn.daxpay.open.payment.merchant.result.dashboard.MchDashboardHeaderCountResult;
import cn.daxpay.open.payment.trade.report.result.AmountRangeItemResult;
import cn.daxpay.open.payment.trade.report.result.DimRankItemResult;
import cn.daxpay.open.payment.trade.report.result.HourlyDistItemResult;
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 cn.daxpay.open.payment.trade.report.support.TradeReportSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
/// # 商户端工作台/分析页交易统计
///
/// 编排 [MchTradeReportMapper](强制 mch_no与 [TradeReportSupport]。
/// 强制使用 [PaymentContext#getMchNo], 禁止请求参数指定任意商户号。
@Service
@RequiredArgsConstructor
public class MchDashboardTradeService {
/// 维度: 通道商户
public static final String DIM_CHANNEL_MCH = "channelMch";
/// 维度: 应用
public static final String DIM_APP = "app";
/// 维度: 门店
public static final String DIM_STORE = "store";
private final PaymentContext paymentContext;
private final MchTradeReportMapper mchTradeReportMapper;
private final TradeReportSupport tradeReportSupport;
private final MchAppInfoManager mchAppInfoManager;
private final MchStoreInfoManager mchStoreInfoManager;
private final ChannelMerchantManager channelMerchantManager;
/// 解析并校验当前商户号
private String requireMchNo() {
String mchNo = paymentContext.getMchNo();
if (mchNo == null || mchNo.isBlank()) {
throw new BizInfoException(CommonCode.FAIL_CODE, "error.payment.merchant.dataErrorNoMchNo");
}
return mchNo;
}
/// 工作台头部: 应用 / 门店 / 通道商户 计数
public MchDashboardHeaderCountResult headerCounts() {
String mchNo = requireMchNo();
long appCount = mchAppInfoManager.lambdaQuery()
.eq(MchAppInfo::getMchNo, mchNo)
.count();
long storeCount = mchStoreInfoManager.lambdaQuery()
.eq(MchStoreInfo::getMchNo, mchNo)
.count();
long channelMerchantCount = channelMerchantManager.lambdaQuery()
.eq(ChannelMerchant::getMchNo, mchNo)
.count();
return new MchDashboardHeaderCountResult()
.setAppCount(appCount)
.setStoreCount(storeCount)
.setChannelMerchantCount(channelMerchantCount);
}
// ===== 概览 =====
public TradeOverviewResult overview(String date) {
String mchNo = requireMchNo();
LocalDate today = LocalDate.now(TradeReportSupport.ZONE_CST);
LocalDate target = "yesterday".equalsIgnoreCase(date) ? today.minusDays(1) : today;
OffsetDateTime start = target.atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
OffsetDateTime end = target.plusDays(1).atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
OffsetDateTime prevStart = start.minusDays(1);
OffsetDateTime prevEnd = start;
return aggregateOverview(mchNo, start, end, prevStart, prevEnd);
}
public TradeOverviewResult overview(String start, String end) {
String mchNo = requireMchNo();
OffsetDateTime startUtc = tradeReportSupport.parseDateStart(start);
OffsetDateTime endUtc = tradeReportSupport.parseDateEndExclusive(end);
long daysSpan = java.time.Duration.between(startUtc, endUtc).toDays();
OffsetDateTime prevStart = startUtc.minusDays(daysSpan);
OffsetDateTime prevEnd = startUtc;
return aggregateOverview(mchNo, startUtc, endUtc, prevStart, prevEnd);
}
private TradeOverviewResult aggregateOverview(
String mchNo,
OffsetDateTime start, OffsetDateTime end,
OffsetDateTime prevStart, OffsetDateTime prevEnd) {
TradeOverviewResult currSuccess = mchTradeReportMapper.successAggregate(start, end, mchNo);
TradeOverviewResult currTotal = mchTradeReportMapper.totalOrdersAggregate(start, end, mchNo);
TradeOverviewResult currRefund = mchTradeReportMapper.refundAggregate(start, end, mchNo);
TradeOverviewResult prevSuccess = mchTradeReportMapper.successAggregate(prevStart, prevEnd, mchNo);
TradeOverviewResult prevTotal = mchTradeReportMapper.totalOrdersAggregate(prevStart, prevEnd, mchNo);
TradeOverviewResult prevRefund = mchTradeReportMapper.refundAggregate(prevStart, prevEnd, mchNo);
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());
}
// ===== 趋势 =====
public List<TradeTrendItemResult> trend(int days) {
String mchNo = requireMchNo();
int safeDays = tradeReportSupport.clampDays(days);
LocalDate today = LocalDate.now(TradeReportSupport.ZONE_CST);
LocalDate startDate = today.minusDays(safeDays - 1L);
OffsetDateTime start = startDate.atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
OffsetDateTime end = today.plusDays(1).atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
return tradeReportSupport.fillTrendDays(
startDate, safeDays, mchTradeReportMapper.trend(start, end, mchNo));
}
public List<TradeTrendItemResult> trend(String startStr, String endStr) {
String mchNo = requireMchNo();
OffsetDateTime start = tradeReportSupport.parseDateStart(startStr);
OffsetDateTime end = tradeReportSupport.parseDateEndExclusive(endStr);
long days = java.time.Duration.between(start, end).toDays();
int safeDays = tradeReportSupport.clampDays((int) days);
LocalDate startDate = start.atZoneSameInstant(TradeReportSupport.ZONE_CST).toLocalDate();
return tradeReportSupport.fillTrendDays(
startDate, safeDays, mchTradeReportMapper.trend(start, end, mchNo));
}
public List<RefundTrendItemResult> refundTrend(int days) {
String mchNo = requireMchNo();
int safeDays = tradeReportSupport.clampDays(days);
LocalDate today = LocalDate.now(TradeReportSupport.ZONE_CST);
LocalDate startDate = today.minusDays(safeDays - 1L);
OffsetDateTime start = startDate.atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
OffsetDateTime end = today.plusDays(1).atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
return tradeReportSupport.fillRefundTrendDays(
startDate, safeDays, mchTradeReportMapper.refundTrend(start, end, mchNo));
}
public List<RefundTrendItemResult> refundTrend(String startStr, String endStr) {
String mchNo = requireMchNo();
OffsetDateTime start = tradeReportSupport.parseDateStart(startStr);
OffsetDateTime end = tradeReportSupport.parseDateEndExclusive(endStr);
long days = java.time.Duration.between(start, end).toDays();
int safeDays = tradeReportSupport.clampDays((int) days);
LocalDate startDate = start.atZoneSameInstant(TradeReportSupport.ZONE_CST).toLocalDate();
return tradeReportSupport.fillRefundTrendDays(
startDate, safeDays, mchTradeReportMapper.refundTrend(start, end, mchNo));
}
// ===== 渠道 =====
public List<ProviderDistItemResult> providerDist(int days) {
String mchNo = requireMchNo();
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
return mchTradeReportMapper.providerDist(range[0], range[1], mchNo);
}
public List<ProviderDistItemResult> providerDist(String startStr, String endStr) {
String mchNo = requireMchNo();
return mchTradeReportMapper.providerDist(
tradeReportSupport.parseDateStart(startStr),
tradeReportSupport.parseDateEndExclusive(endStr),
mchNo);
}
public List<ProviderSuccessItemResult> providerSuccess(int days) {
String mchNo = requireMchNo();
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
return mchTradeReportMapper.providerSuccess(range[0], range[1], mchNo);
}
public List<ProviderSuccessItemResult> providerSuccess(String startStr, String endStr) {
String mchNo = requireMchNo();
return mchTradeReportMapper.providerSuccess(
tradeReportSupport.parseDateStart(startStr),
tradeReportSupport.parseDateEndExclusive(endStr),
mchNo);
}
// ===== 时段 / 金额 =====
public List<HourlyDistItemResult> hourlyDist(int days) {
String mchNo = requireMchNo();
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
int daysSpan = tradeReportSupport.clampDays(days);
return tradeReportSupport.toDailyAverage(
tradeReportSupport.fillHourlyDist(mchTradeReportMapper.hourlyDist(range[0], range[1], mchNo)),
daysSpan);
}
public List<HourlyDistItemResult> hourlyDist(String startStr, String endStr) {
String mchNo = requireMchNo();
OffsetDateTime start = tradeReportSupport.parseDateStart(startStr);
OffsetDateTime end = tradeReportSupport.parseDateEndExclusive(endStr);
long daysSpan = Math.max(1L, java.time.Duration.between(start, end).toDays());
return tradeReportSupport.toDailyAverage(
tradeReportSupport.fillHourlyDist(mchTradeReportMapper.hourlyDist(start, end, mchNo)),
daysSpan);
}
public List<AmountRangeItemResult> amountRange(int days) {
String mchNo = requireMchNo();
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
return tradeReportSupport.fillAmountRange(mchTradeReportMapper.amountRange(range[0], range[1], mchNo));
}
public List<AmountRangeItemResult> amountRange(String startStr, String endStr) {
String mchNo = requireMchNo();
return tradeReportSupport.fillAmountRange(mchTradeReportMapper.amountRange(
tradeReportSupport.parseDateStart(startStr),
tradeReportSupport.parseDateEndExclusive(endStr),
mchNo));
}
// ===== 维度排名 =====
/// @param dim channelMch | app | store
public List<DimRankItemResult> dimRank(String dim, int days, int limit) {
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
return dimRank(requireMchNo(), dim, range[0], range[1], limit);
}
public List<DimRankItemResult> dimRank(String dim, String startStr, String endStr, int limit) {
return dimRank(
requireMchNo(),
dim,
tradeReportSupport.parseDateStart(startStr),
tradeReportSupport.parseDateEndExclusive(endStr),
limit);
}
private List<DimRankItemResult> dimRank(
String mchNo, String dim, OffsetDateTime start, OffsetDateTime end, int limit) {
int safeLimit = tradeReportSupport.clampLimit(limit);
String normalized = dim == null ? DIM_APP : dim.trim();
List<DimRankItemResult> rows = switch (normalized) {
case DIM_CHANNEL_MCH -> mchTradeReportMapper.dimRankByChannelMch(start, end, mchNo, safeLimit);
case DIM_STORE -> mchTradeReportMapper.dimRankByStore(start, end, mchNo, safeLimit);
default -> mchTradeReportMapper.dimRankByApp(start, end, mchNo, safeLimit);
};
return tradeReportSupport.computeDimProportion(rows);
}
}

View File

@@ -0,0 +1,42 @@
package cn.daxpay.open.payment.merchant.service.trade;
import cn.daxpay.open.platform.core.rest.param.PageParam;
import cn.daxpay.open.platform.core.rest.result.PageResult;
import cn.daxpay.open.payment.merchant.convert.trade.MchPayTradeConvert;
import cn.daxpay.open.payment.trade.order.dao.PayTradeManager;
import cn.daxpay.open.payment.trade.order.entity.PayTrade;
import cn.daxpay.open.payment.trade.order.param.PayTradeQuery;
import cn.daxpay.open.payment.trade.order.result.PayTradeResult;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/// # 资金交易凭证查询(商户端)
///
/// 仅分页列表;行级隔离依赖 [MchBaseEntity] + 商户端 PaymentContext / TenantLine。
/// 禁止信任请求体中的 mchNo查询条件亦不作为跨商户入口
@Slf4j
@Service
@RequiredArgsConstructor
public class MchPayTradeService {
private final PayTradeManager payTradeManager;
/// 分页查询本商户资金交易
public PageResult<PayTradeResult> page(PageParam pageParam, PayTradeQuery query) {
// 忽略入参 mchNo避免越权指定他商户
if (query != null) {
query.setMchNo(null);
}
Page<PayTrade> page = payTradeManager.page(pageParam, query);
var records = page.getRecords().stream()
.map(MchPayTradeConvert.CONVERT::toResult)
.toList();
return new PageResult<PayTradeResult>()
.setRecords(records)
.setTotal(page.getTotal())
.setSize(page.getSize())
.setCurrent(page.getCurrent());
}
}