diff --git a/_config/sql/iam_perm_menu.sql b/_config/sql/iam_perm_menu.sql index 1d6453d2e..8933b4da3 100644 --- a/_config/sql/iam_perm_menu.sql +++ b/_config/sql/iam_perm_menu.sql @@ -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); diff --git a/_config/sql/update-datas.sql b/_config/sql/update-datas.sql index 4bfaf15f4..9d2fdfebb 100644 --- a/_config/sql/update-datas.sql +++ b/_config/sql/update-datas.sql @@ -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 + ); diff --git a/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/dashboard/DashboardTradeController.java b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/dashboard/AdminDashboardTradeController.java similarity index 79% rename from daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/dashboard/DashboardTradeController.java rename to daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/dashboard/AdminDashboardTradeController.java index 54cd93cdb..1510cf3d7 100644 --- a/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/dashboard/DashboardTradeController.java +++ b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/dashboard/AdminDashboardTradeController.java @@ -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)); } } diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/dao/TradeReportMapper.java b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/dao/dashboard/AdminTradeReportMapper.java similarity index 66% rename from daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/dao/TradeReportMapper.java rename to daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/dao/dashboard/AdminTradeReportMapper.java index 837ed0c5f..0a19a26f6 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/dao/TradeReportMapper.java +++ b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/dao/dashboard/AdminTradeReportMapper.java @@ -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 { +public interface AdminTradeReportMapper extends MPJBaseMapper { // ===== 概览 ===== @@ -57,8 +41,7 @@ public interface TradeReportMapper extends MPJBaseMapper { @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 { @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 { // ===== 趋势 ===== - /// 成交趋势: 按 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 { @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 { // ===== 渠道分布 ===== - /// 支付渠道分布: 按支付渠道(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 { @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 { // ===== 时段分布 ===== - /// 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 { // ===== 金额区间分桶 ===== - /// 金额区间分桶: 按 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 { // ===== 商户排名 ===== - /// 商户交易额排名: 按 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 " diff --git a/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/dashboard/AdminDashboardTradeService.java b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/dashboard/AdminDashboardTradeService.java new file mode 100644 index 000000000..da0f40ce5 --- /dev/null +++ b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/dashboard/AdminDashboardTradeService.java @@ -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 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 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 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 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 providerDist(int days) { + OffsetDateTime[] range = tradeReportSupport.daysRange(days); + return adminTradeReportMapper.providerDist(range[0], range[1]); + } + + public List providerDist(String startStr, String endStr) { + return adminTradeReportMapper.providerDist( + tradeReportSupport.parseDateStart(startStr), + tradeReportSupport.parseDateEndExclusive(endStr)); + } + + public List providerSuccess(int days) { + OffsetDateTime[] range = tradeReportSupport.daysRange(days); + return adminTradeReportMapper.providerSuccess(range[0], range[1]); + } + + public List providerSuccess(String startStr, String endStr) { + return adminTradeReportMapper.providerSuccess( + tradeReportSupport.parseDateStart(startStr), + tradeReportSupport.parseDateEndExclusive(endStr)); + } + + // ===== 时段 / 金额 ===== + + public List 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 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 amountRange(int days) { + OffsetDateTime[] range = tradeReportSupport.daysRange(days); + return tradeReportSupport.fillAmountRange(adminTradeReportMapper.amountRange(range[0], range[1])); + } + + public List amountRange(String startStr, String endStr) { + return tradeReportSupport.fillAmountRange(adminTradeReportMapper.amountRange( + tradeReportSupport.parseDateStart(startStr), + tradeReportSupport.parseDateEndExclusive(endStr))); + } + + // ===== 商户排名 ===== + + public List merchantRank(int days, int limit) { + OffsetDateTime[] range = tradeReportSupport.daysRange(days); + return tradeReportSupport.computeMerchantProportion( + adminTradeReportMapper.merchantRank(range[0], range[1], tradeReportSupport.clampLimit(limit))); + } + + public List merchantRank(String startStr, String endStr, int limit) { + return tradeReportSupport.computeMerchantProportion(adminTradeReportMapper.merchantRank( + tradeReportSupport.parseDateStart(startStr), + tradeReportSupport.parseDateEndExclusive(endStr), + tradeReportSupport.clampLimit(limit))); + } +} diff --git a/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/dashboard/DashboardTradeService.java b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/dashboard/DashboardTradeService.java deleted file mode 100644 index a6463215b..000000000 --- a/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/dashboard/DashboardTradeService.java +++ /dev/null @@ -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 AMOUNT_BUCKETS = List.of("0-50", "50-200", "200-1000", "1000-5000", "5000+"); - - private final TradeReportMapper tradeReportMapper; - - // ===== 概览 ===== - - /// 交易概览(今日/昨日) - /// - /// @param date "today" 或 "yesterday", 其它值按 today 处理 - public TradeOverviewResult overview(String date) { - LocalDate today = LocalDate.now(ZONE_CST); - LocalDate target = "yesterday".equalsIgnoreCase(date) ? today.minusDays(1) : today; - // [start, end) 半开区间, end 指向次日 00:00 避免边界重复 - OffsetDateTime start = target.atStartOfDay(ZONE_CST).toOffsetDateTime(); - OffsetDateTime end = target.plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime(); - // 上一周期: 同长度的前一天(单日概览时即昨日, 与 date=yesterday 不同语义, 这里 prev 用于环比) - OffsetDateTime prevStart = start.minusDays(1); - OffsetDateTime prevEnd = start; - return aggregateOverview(start, end, prevStart, prevEnd); - } - - /// 交易概览(自定义区间, 含上期对比) - /// - /// @param start 起始日期(yyyy-MM-dd, 包含), 业务时区 Asia/Shanghai - /// @param end 结束日期(yyyy-MM-dd, **包含**), Service 内部转半开区间 [start, start+1day) - public TradeOverviewResult overview(String start, String end) { - OffsetDateTime startUtc = parseDateStart(start); - OffsetDateTime endUtc = parseDateEndExclusive(end); - // 上一周期: 与本区间等长, 紧接本区间之前 - long daysSpan = java.time.Duration.between(startUtc, endUtc).toDays(); - OffsetDateTime prevStart = startUtc.minusDays(daysSpan); - OffsetDateTime prevEnd = startUtc; - return aggregateOverview(startUtc, endUtc, prevStart, prevEnd); - } - - /// 聚合概览(本/上期 6 次查询组装到一个 Result) - private TradeOverviewResult aggregateOverview( - OffsetDateTime start, OffsetDateTime end, - OffsetDateTime prevStart, OffsetDateTime prevEnd) { - // 本期 - TradeOverviewResult currSuccess = tradeReportMapper.successAggregate(start, end); - TradeOverviewResult currTotal = tradeReportMapper.totalOrdersAggregate(start, end); - TradeOverviewResult currRefund = tradeReportMapper.refundAggregate(start, end); - // 上期(用于环比) - TradeOverviewResult prevSuccess = tradeReportMapper.successAggregate(prevStart, prevEnd); - TradeOverviewResult prevTotal = tradeReportMapper.totalOrdersAggregate(prevStart, prevEnd); - TradeOverviewResult prevRefund = tradeReportMapper.refundAggregate(prevStart, prevEnd); - - return new TradeOverviewResult() - .setSuccessAmount(currSuccess.getSuccessAmount()) - .setSuccessCount(currSuccess.getSuccessCount()) - .setRefundAmount(currRefund.getRefundAmount()) - .setRefundCount(currRefund.getRefundCount()) - .setTotalOrders(currTotal.getTotalOrders()) - .setPrevSuccessAmount(prevSuccess.getSuccessAmount()) - .setPrevSuccessCount(prevSuccess.getSuccessCount()) - .setPrevRefundAmount(prevRefund.getRefundAmount()) - .setPrevRefundCount(prevRefund.getRefundCount()) - .setPrevTotalOrders(prevTotal.getTotalOrders()); - } - - // ===== 趋势 ===== - - /// 交易趋势(指定天数, 近 N 天每日成交金额与笔数) - /// - /// @param days 近 N 天(含今天), 钳制到 [1, 365] - public List trend(int days) { - int safeDays = clampDays(days); - LocalDate today = LocalDate.now(ZONE_CST); - LocalDate startDate = today.minusDays(safeDays - 1L); - OffsetDateTime start = startDate.atStartOfDay(ZONE_CST).toOffsetDateTime(); - OffsetDateTime end = today.plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime(); - return fillTrendDays(startDate, safeDays, tradeReportMapper.trend(start, end)); - } - - /// 交易趋势(自定义区间) - public List trend(String startStr, String endStr) { - OffsetDateTime start = parseDateStart(startStr); - OffsetDateTime end = parseDateEndExclusive(endStr); - long days = java.time.Duration.between(start, end).toDays(); - int safeDays = clampDays((int) days); - LocalDate startDate = start.atZoneSameInstant(ZONE_CST).toLocalDate(); - List rows = tradeReportMapper.trend(start, end); - return fillTrendDays(startDate, safeDays, rows); - } - - /// 退款趋势(指定天数) - public List refundTrend(int days) { - int safeDays = clampDays(days); - LocalDate today = LocalDate.now(ZONE_CST); - LocalDate startDate = today.minusDays(safeDays - 1L); - OffsetDateTime start = startDate.atStartOfDay(ZONE_CST).toOffsetDateTime(); - OffsetDateTime end = today.plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime(); - return fillRefundTrendDays(startDate, safeDays, tradeReportMapper.refundTrend(start, end)); - } - - /// 退款趋势(自定义区间) - public List refundTrend(String startStr, String endStr) { - OffsetDateTime start = parseDateStart(startStr); - OffsetDateTime end = parseDateEndExclusive(endStr); - long days = java.time.Duration.between(start, end).toDays(); - int safeDays = clampDays((int) days); - LocalDate startDate = start.atZoneSameInstant(ZONE_CST).toLocalDate(); - return fillRefundTrendDays(startDate, safeDays, tradeReportMapper.refundTrend(start, end)); - } - - // ===== 渠道分布 ===== - - /// 支付渠道分布(指定天数, 各支付渠道成交金额与笔数) - public List providerDist(int days) { - OffsetDateTime[] range = daysRange(days); - return tradeReportMapper.providerDist(range[0], range[1]); - } - - /// 支付渠道分布(自定义区间) - public List providerDist(String startStr, String endStr) { - return tradeReportMapper.providerDist(parseDateStart(startStr), parseDateEndExclusive(endStr)); - } - - /// 支付渠道成功率(指定天数) - public List providerSuccess(int days) { - OffsetDateTime[] range = daysRange(days); - return tradeReportMapper.providerSuccess(range[0], range[1]); - } - - /// 支付渠道成功率(自定义区间) - public List providerSuccess(String startStr, String endStr) { - return tradeReportMapper.providerSuccess(parseDateStart(startStr), parseDateEndExclusive(endStr)); - } - - // ===== 时段分布 ===== - - /// 时段分布(日均, 指定天数): 区间内按小时汇总后 ÷ 天数, 补齐 0-23 - public List 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 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 amountRange(int days) { - OffsetDateTime[] range = daysRange(days); - return fillAmountRange(tradeReportMapper.amountRange(range[0], range[1])); - } - - /// 金额区间分桶(自定义区间) - public List amountRange(String startStr, String endStr) { - return fillAmountRange(tradeReportMapper.amountRange(parseDateStart(startStr), parseDateEndExclusive(endStr))); - } - - // ===== 商户排名 ===== - - /// 商户交易额排名(指定天数, 含占比计算) - /// - /// @param days 近 N 天(含今天) - /// @param limit 返回前 N 名(默认 10, 上限 50) - public List merchantRank(int days, int limit) { - OffsetDateTime[] range = daysRange(days); - return computeProportion(tradeReportMapper.merchantRank(range[0], range[1], clampLimit(limit))); - } - - /// 商户交易额排名(自定义区间) - public List merchantRank(String startStr, String endStr, int limit) { - return computeProportion(tradeReportMapper.merchantRank( - parseDateStart(startStr), parseDateEndExclusive(endStr), clampLimit(limit))); - } - - // ===== 内部工具 ===== - - /// 钳制天数到 [1, 365] - private int clampDays(int days) { - return Math.max(TREND_DAYS_MIN, Math.min(days, TREND_DAYS_MAX)); - } - - /// 钳制商户排名 limit 到 [1, 50], 默认 10 - private int clampLimit(int limit) { - if (limit <= 0) { - return MERCHANT_RANK_LIMIT_DEFAULT; - } - return Math.min(limit, MERCHANT_RANK_LIMIT_MAX); - } - - /// 近 N 天(含今天)的 [start, end) 区间 - private OffsetDateTime[] daysRange(int days) { - int safeDays = clampDays(days); - LocalDate today = LocalDate.now(ZONE_CST); - OffsetDateTime start = today.minusDays(safeDays - 1L).atStartOfDay(ZONE_CST).toOffsetDateTime(); - OffsetDateTime end = today.plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime(); - return new OffsetDateTime[]{start, end}; - } - - /// 解析 yyyy-MM-dd 为该日 00:00:00 Asia/Shanghai 的 OffsetDateTime - private OffsetDateTime parseDateStart(String date) { - return LocalDate.parse(date, DATE_FMT).atStartOfDay(ZONE_CST).toOffsetDateTime(); - } - - /// 解析 yyyy-MM-dd 为次日 00:00:00 Asia/Shanghai 的 OffsetDateTime (半开区间 end) - private OffsetDateTime parseDateEndExclusive(String date) { - return LocalDate.parse(date, DATE_FMT).plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime(); - } - - /// 趋势补零: 对完整日期区间补齐缺失日(amount=0, count=0) - private List fillTrendDays(LocalDate startDate, int days, List rows) { - Map indexed = new HashMap<>(rows.size()); - for (TradeTrendItemResult row : rows) { - indexed.put(row.getDate(), row); - } - List result = new ArrayList<>(days); - for (int i = 0; i < days; i++) { - String key = startDate.plusDays(i).format(DATE_FMT); - TradeTrendItemResult row = indexed.get(key); - if (row != null) { - if (row.getCount() == null) { - row.setCount(0L); - } - result.add(row); - } else { - result.add(new TradeTrendItemResult().setDate(key).setAmount(0L).setCount(0L)); - } - } - return result; - } - - /// 退款趋势补零 - private List fillRefundTrendDays(LocalDate startDate, int days, List rows) { - Map indexed = new HashMap<>(rows.size()); - for (RefundTrendItemResult row : rows) { - indexed.put(row.getDate(), row); - } - List result = new ArrayList<>(days); - for (int i = 0; i < days; i++) { - String key = startDate.plusDays(i).format(DATE_FMT); - RefundTrendItemResult row = indexed.get(key); - if (row != null) { - if (row.getCount() == null) { - row.setCount(0L); - } - result.add(row); - } else { - result.add(new RefundTrendItemResult().setDate(key).setAmount(0L).setCount(0L)); - } - } - return result; - } - - /// 时段分布补齐 0-23 缺失小时(此时仍为区间合计, 日均见 [toDailyAverage]) - private List fillHourlyDist(List rows) { - Map indexed = new HashMap<>(rows.size()); - for (HourlyDistItemResult row : rows) { - indexed.put(row.getHour(), row); - } - List result = new ArrayList<>(24); - for (int h = 0; h < 24; h++) { - HourlyDistItemResult row = indexed.get(h); - if (row != null) { - 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 toDailyAverage(List 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 fillAmountRange(List rows) { - Map indexed = new HashMap<>(rows.size()); - for (AmountRangeItemResult row : rows) { - indexed.put(row.getBucket(), row.getCount()); - } - List result = new ArrayList<>(AMOUNT_BUCKETS.size()); - for (String bucket : AMOUNT_BUCKETS) { - result.add(new AmountRangeItemResult() - .setBucket(bucket) - .setCount(indexed.getOrDefault(bucket, 0L))); - } - return result; - } - - /// 商户排名占比计算(基于本期返回的 amount 总和, 非全平台总额; 取 top N 内部相对占比) - /// 注意: 这只是 top N 内部的相对占比, 分母为 top N 总额, 不是全平台总额 - private List computeProportion(List rows) { - long total = rows.stream().mapToLong(MerchantRankItemResult::getAmount).sum(); - for (MerchantRankItemResult row : rows) { - double proportion = total > 0 - ? Math.round(row.getAmount() * 1000.0 / total) / 10.0 - : 0.0; - row.setProportion(proportion); - } - return rows; - } -} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/DimRankItemResult.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/DimRankItemResult.java new file mode 100644 index 000000000..b56e94242 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/DimRankItemResult.java @@ -0,0 +1,35 @@ +package cn.daxpay.open.payment.trade.report.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +/// # 商户端维度交易额排名单项 +/// +/// 在本商户下按通道商户 / 应用 / 门店聚合; 金额单位分。 +/// 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; +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/TradeOverviewResult.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/TradeOverviewResult.java index 4480da485..23ddfa046 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/TradeOverviewResult.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/result/TradeOverviewResult.java @@ -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 = "交易概览统计") diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/support/TradeReportSupport.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/support/TradeReportSupport.java new file mode 100644 index 000000000..9786cd475 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/report/support/TradeReportSupport.java @@ -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 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 fillTrendDays(LocalDate startDate, int days, List rows) { + Map indexed = new HashMap<>(rows.size()); + for (TradeTrendItemResult row : rows) { + indexed.put(row.getDate(), row); + } + List result = new ArrayList<>(days); + for (int i = 0; i < days; i++) { + String key = startDate.plusDays(i).format(DATE_FMT); + TradeTrendItemResult row = indexed.get(key); + if (row != null) { + if (row.getCount() == null) { + row.setCount(0L); + } + result.add(row); + } else { + result.add(new TradeTrendItemResult().setDate(key).setAmount(0L).setCount(0L)); + } + } + return result; + } + + /// 退款趋势按日补零 + public List fillRefundTrendDays(LocalDate startDate, int days, List rows) { + Map indexed = new HashMap<>(rows.size()); + for (RefundTrendItemResult row : rows) { + indexed.put(row.getDate(), row); + } + List result = new ArrayList<>(days); + for (int i = 0; i < days; i++) { + String key = startDate.plusDays(i).format(DATE_FMT); + RefundTrendItemResult row = indexed.get(key); + if (row != null) { + if (row.getCount() == null) { + row.setCount(0L); + } + result.add(row); + } else { + result.add(new RefundTrendItemResult().setDate(key).setAmount(0L).setCount(0L)); + } + } + return result; + } + + /// 时段分布补齐 0-23 + public List fillHourlyDist(List rows) { + Map indexed = new HashMap<>(rows.size()); + for (HourlyDistItemResult row : rows) { + indexed.put(row.getHour(), row); + } + List result = new ArrayList<>(24); + for (int h = 0; h < 24; h++) { + HourlyDistItemResult row = indexed.get(h); + if (row != null) { + 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 toDailyAverage(List 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 fillAmountRange(List rows) { + Map indexed = new HashMap<>(rows.size()); + for (AmountRangeItemResult row : rows) { + indexed.put(row.getBucket(), row.getCount()); + } + List result = new ArrayList<>(AMOUNT_BUCKETS.size()); + for (String bucket : AMOUNT_BUCKETS) { + result.add(new AmountRangeItemResult() + .setBucket(bucket) + .setCount(indexed.getOrDefault(bucket, 0L))); + } + return result; + } + + /// 运营端商户排名占比(一位小数百分比) + public List computeMerchantProportion(List rows) { + long total = rows.stream().mapToLong(MerchantRankItemResult::getAmount).sum(); + for (MerchantRankItemResult row : rows) { + double proportion = total > 0 + ? Math.round(row.getAmount() * 1000.0 / total) / 10.0 + : 0.0; + row.setProportion(proportion); + } + return rows; + } + + /// 商户端维度排名占比 + public List computeDimProportion(List 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; + } +} diff --git a/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/controller/dashboard/MchDashboardTradeController.java b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/controller/dashboard/MchDashboardTradeController.java new file mode 100644 index 000000000..bcafc78f7 --- /dev/null +++ b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/controller/dashboard/MchDashboardTradeController.java @@ -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 headerCounts() { + return Res.ok(mchDashboardTradeService.headerCounts()); + } + + @Operation(summary = "交易概览(今日/昨日或自定义区间)") + @GetMapping("/overview") + public Result overview( + @RequestParam(required = false) String date, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end) { + if (start != null && end != null) { + return Res.ok(mchDashboardTradeService.overview(start, end)); + } + return Res.ok(mchDashboardTradeService.overview(date != null ? date : "today")); + } + + @Operation(summary = "交易趋势") + @GetMapping("/trend") + public Result> trend( + @RequestParam(defaultValue = "7") int days, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end) { + if (start != null && end != null) { + return Res.ok(mchDashboardTradeService.trend(start, end)); + } + return Res.ok(mchDashboardTradeService.trend(days)); + } + + @Operation(summary = "退款趋势") + @GetMapping("/refund-trend") + public Result> refundTrend( + @RequestParam(defaultValue = "7") int days, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end) { + if (start != null && end != null) { + return Res.ok(mchDashboardTradeService.refundTrend(start, end)); + } + return Res.ok(mchDashboardTradeService.refundTrend(days)); + } + + @Operation(summary = "支付渠道分布") + @GetMapping("/provider-dist") + public Result> providerDist( + @RequestParam(defaultValue = "30") int days, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end) { + if (start != null && end != null) { + return Res.ok(mchDashboardTradeService.providerDist(start, end)); + } + return Res.ok(mchDashboardTradeService.providerDist(days)); + } + + @Operation(summary = "支付渠道成功率") + @GetMapping("/provider-success") + public Result> providerSuccess( + @RequestParam(defaultValue = "30") int days, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end) { + if (start != null && end != null) { + return Res.ok(mchDashboardTradeService.providerSuccess(start, end)); + } + return Res.ok(mchDashboardTradeService.providerSuccess(days)); + } + + @Operation(summary = "时段分布(日均)") + @GetMapping("/hourly-dist") + public Result> hourlyDist( + @RequestParam(defaultValue = "7") int days, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end) { + if (start != null && end != null) { + return Res.ok(mchDashboardTradeService.hourlyDist(start, end)); + } + return Res.ok(mchDashboardTradeService.hourlyDist(days)); + } + + @Operation(summary = "金额区间分桶") + @GetMapping("/amount-range") + public Result> amountRange( + @RequestParam(defaultValue = "7") int days, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end) { + if (start != null && end != null) { + return Res.ok(mchDashboardTradeService.amountRange(start, end)); + } + return Res.ok(mchDashboardTradeService.amountRange(days)); + } + + /// 维度排行: dim=channelMch|app|store + @Operation(summary = "维度交易额排名(通道商户/应用/门店)") + @GetMapping("/dim-rank") + public Result> 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)); + } +} diff --git a/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/controller/trade/MchPayTradeController.java b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/controller/trade/MchPayTradeController.java new file mode 100644 index 000000000..0e00ca006 --- /dev/null +++ b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/controller/trade/MchPayTradeController.java @@ -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> page(PageParam pageParam, PayTradeQuery query) { + return Res.ok(mchPayTradeService.page(pageParam, query)); + } +} diff --git a/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/convert/trade/MchPayTradeConvert.java b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/convert/trade/MchPayTradeConvert.java new file mode 100644 index 000000000..e541afa74 --- /dev/null +++ b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/convert/trade/MchPayTradeConvert.java @@ -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); +} diff --git a/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/dao/dashboard/MchTradeReportMapper.java b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/dao/dashboard/MchTradeReportMapper.java new file mode 100644 index 000000000..322187990 --- /dev/null +++ b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/dao/dashboard/MchTradeReportMapper.java @@ -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 { + + // ===== 概览 ===== + + /// 成交聚合: 按 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 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 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 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 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 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 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 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 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 dimRankByStore( + @Param("start") OffsetDateTime start, + @Param("end") OffsetDateTime end, + @Param("mchNo") String mchNo, + @Param("limit") int limit); +} diff --git a/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/result/dashboard/MchDashboardHeaderCountResult.java b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/result/dashboard/MchDashboardHeaderCountResult.java new file mode 100644 index 000000000..e2776c2e9 --- /dev/null +++ b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/result/dashboard/MchDashboardHeaderCountResult.java @@ -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; +} diff --git a/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/service/dashboard/MchDashboardTradeService.java b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/service/dashboard/MchDashboardTradeService.java new file mode 100644 index 000000000..e08750f4d --- /dev/null +++ b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/service/dashboard/MchDashboardTradeService.java @@ -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 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 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 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 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 providerDist(int days) { + String mchNo = requireMchNo(); + OffsetDateTime[] range = tradeReportSupport.daysRange(days); + return mchTradeReportMapper.providerDist(range[0], range[1], mchNo); + } + + public List providerDist(String startStr, String endStr) { + String mchNo = requireMchNo(); + return mchTradeReportMapper.providerDist( + tradeReportSupport.parseDateStart(startStr), + tradeReportSupport.parseDateEndExclusive(endStr), + mchNo); + } + + public List providerSuccess(int days) { + String mchNo = requireMchNo(); + OffsetDateTime[] range = tradeReportSupport.daysRange(days); + return mchTradeReportMapper.providerSuccess(range[0], range[1], mchNo); + } + + public List providerSuccess(String startStr, String endStr) { + String mchNo = requireMchNo(); + return mchTradeReportMapper.providerSuccess( + tradeReportSupport.parseDateStart(startStr), + tradeReportSupport.parseDateEndExclusive(endStr), + mchNo); + } + + // ===== 时段 / 金额 ===== + + public List 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 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 amountRange(int days) { + String mchNo = requireMchNo(); + OffsetDateTime[] range = tradeReportSupport.daysRange(days); + return tradeReportSupport.fillAmountRange(mchTradeReportMapper.amountRange(range[0], range[1], mchNo)); + } + + public List 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 dimRank(String dim, int days, int limit) { + OffsetDateTime[] range = tradeReportSupport.daysRange(days); + return dimRank(requireMchNo(), dim, range[0], range[1], limit); + } + + public List dimRank(String dim, String startStr, String endStr, int limit) { + return dimRank( + requireMchNo(), + dim, + tradeReportSupport.parseDateStart(startStr), + tradeReportSupport.parseDateEndExclusive(endStr), + limit); + } + + private List 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 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); + } +} diff --git a/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/service/trade/MchPayTradeService.java b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/service/trade/MchPayTradeService.java new file mode 100644 index 000000000..8c931fefb --- /dev/null +++ b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/service/trade/MchPayTradeService.java @@ -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 page(PageParam pageParam, PayTradeQuery query) { + // 忽略入参 mchNo,避免越权指定他商户 + if (query != null) { + query.setMchNo(null); + } + Page page = payTradeManager.page(pageParam, query); + var records = page.getRecords().stream() + .map(MchPayTradeConvert.CONVERT::toResult) + .toList(); + return new PageResult() + .setRecords(records) + .setTotal(page.getTotal()) + .setSize(page.getSize()) + .setCurrent(page.getCurrent()); + } +}