diff --git a/dashboard-ui/app.js b/dashboard-ui/app.js index 6ec92bd..3732c05 100644 --- a/dashboard-ui/app.js +++ b/dashboard-ui/app.js @@ -12,6 +12,7 @@ let tagChart; document.addEventListener('DOMContentLoaded', () => { initCharts(); setupNavigation(); + setupRepurchasePeriod(); // Fetch cutoff dates + initial page data fetchDataCutoff(); @@ -181,9 +182,14 @@ async function fetchTrend() { } // ============== PAGE 2: PRODUCT INSIGHTS ============== -async function fetchRepurchase() { + +// 当前选中的复购率统计周期 +let repurchaseDays = 30; + +async function fetchRepurchase(days) { + if (days !== undefined) repurchaseDays = days; try { - const response = await fetch(`${BASE_URL}/product/repurchase`); + const response = await fetch(`${BASE_URL}/product/repurchase?days=${repurchaseDays}`); if (!response.ok) return; const data = await response.json(); @@ -215,6 +221,20 @@ async function fetchRepurchase() { } catch (e) {} } +// 初始化复购率周期切换控件 +function setupRepurchasePeriod() { + const container = document.getElementById('repurchase-period'); + if (!container) return; + container.addEventListener('click', (e) => { + const btn = e.target.closest('.segment-btn'); + if (!btn) return; + container.querySelectorAll('.segment-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const days = parseInt(btn.dataset.days); + fetchRepurchase(days); + }); +} + async function fetchBasket() { try { const response = await fetch(`${BASE_URL}/product/basket`); diff --git a/dashboard-ui/index.html b/dashboard-ui/index.html index 1348c8a..585d9c7 100644 --- a/dashboard-ui/index.html +++ b/dashboard-ui/index.html @@ -4,10 +4,7 @@ 餐饮零售数据中台 | 核心引擎 - - - - + @@ -92,17 +89,22 @@
-

商品30天复购率与波士顿矩阵

+

商品复购率与波士顿矩阵

+
+ + + +
- - + + - + diff --git a/dashboard-ui/styles.css b/dashboard-ui/styles.css index d365fb6..f36aea9 100644 --- a/dashboard-ui/styles.css +++ b/dashboard-ui/styles.css @@ -36,7 +36,7 @@ margin: 0; padding: 0; box-sizing: border-box; - font-family: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; } @@ -302,6 +302,52 @@ body { box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08); } +/* Chart header with optional controls */ +.chart-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 1rem; +} + +.chart-header h2 { + margin: 0; +} + +/* Apple-style Segmented Control */ +.segment-control { + display: inline-flex; + background: rgba(134, 134, 139, 0.08); + border-radius: 10px; + padding: 3px; + gap: 2px; +} + +.segment-btn { + padding: 5px 16px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.25s ease; + font-family: inherit; +} + +.segment-btn:hover { + color: var(--text-primary); +} + +.segment-btn.active { + background: var(--text-primary); + color: var(--bg-base); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); +} + /* Dashboard Grid */ .dashboard-grid { display: flex; diff --git a/server/src/main/java/com/chuyishidai/datahub/controller/ProductController.java b/server/src/main/java/com/chuyishidai/datahub/controller/ProductController.java index 6503152..e6c2256 100644 --- a/server/src/main/java/com/chuyishidai/datahub/controller/ProductController.java +++ b/server/src/main/java/com/chuyishidai/datahub/controller/ProductController.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.JdbcTemplate; 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; @@ -55,22 +56,74 @@ public class ProductController { } /** - * 商品30天复购率与波士顿矩阵打标 + * 商品复购率与波士顿矩阵打标 + * @param days 统计周期: 7 / 15 / 30 (默认30) + * - 30天: 直接读取预计算表 adm_item_repurchase + * - 7/15天: 从订单明细实时计算 */ @GetMapping("/repurchase") - public List> getRepurchaseRanking() { - String sql = """ + public List> getRepurchaseRanking( + @RequestParam(value = "days", defaultValue = "30") int days) { + + // 30天走预计算表 (洞察计算已写入) + if (days == 30) { + String sql = """ + SELECT + item_name, outer_item_id, + stat_date, + purchaser_count_30d, + repurchaser_count_30d, + repurchase_rate_30d, + matrix_tag + FROM adm_item_repurchase + WHERE stat_date = (SELECT MAX(stat_date) FROM adm_item_repurchase) + ORDER BY repurchase_rate_30d DESC, purchaser_count_30d DESC + """; + return jdbcTemplate.queryForList(sql); + } + + // 白名单校验,防止 SQL 注入 + if (days != 7 && days != 15) days = 30; + + // 7/15天: 实时计算 (MySQL 不支持 INTERVAL ? DAY 参数化) + String sql = String.format(""" + WITH + UserItemOrders AS ( + SELECT + o.yz_open_id, o.outer_item_id, + COUNT(DISTINCT o.tid) AS buy_freq + FROM dwd_trade_order_detail o + WHERE o.pay_time >= DATE_SUB(CURDATE(), INTERVAL %d DAY) + AND o.yz_open_id IS NOT NULL AND o.yz_open_id != '' + AND o.outer_item_id IS NOT NULL AND o.outer_item_id != '' + AND o.title NOT LIKE '%%餐具%%' + GROUP BY o.yz_open_id, o.outer_item_id + ), + Aggregated AS ( + SELECT + outer_item_id, + COUNT(yz_open_id) AS purchaser_count_30d, + SUM(CASE WHEN buy_freq >= 2 THEN 1 ELSE 0 END) AS repurchaser_count_30d + FROM UserItemOrders + GROUP BY outer_item_id + ) SELECT - item_name, outer_item_id, - stat_date, - purchaser_count_30d, - repurchaser_count_30d, - repurchase_rate_30d, - matrix_tag - FROM adm_item_repurchase - WHERE stat_date = (SELECT MAX(stat_date) FROM adm_item_repurchase) + (SELECT MAX(d.title) FROM dwd_trade_order_detail d + WHERE d.outer_item_id = a.outer_item_id AND d.title IS NOT NULL) AS item_name, + a.outer_item_id, + CURDATE() AS stat_date, + a.purchaser_count_30d, + a.repurchaser_count_30d, + CAST(a.repurchaser_count_30d * 1.0 / a.purchaser_count_30d AS DECIMAL(5,4)) AS repurchase_rate_30d, + CASE + WHEN a.purchaser_count_30d >= 20 AND (a.repurchaser_count_30d * 1.0 / a.purchaser_count_30d) >= 0.2 THEN '核心引流爆款' + WHEN a.purchaser_count_30d >= 20 AND (a.repurchaser_count_30d * 1.0 / a.purchaser_count_30d) < 0.05 THEN '体验差需淘汰' + WHEN a.purchaser_count_30d < 10 AND (a.repurchaser_count_30d * 1.0 / a.purchaser_count_30d) >= 0.3 THEN '小众潜力款' + ELSE '平庸款' + END AS matrix_tag + FROM Aggregated a ORDER BY repurchase_rate_30d DESC, purchaser_count_30d DESC - """; + """, days); return jdbcTemplate.queryForList(sql); } } diff --git a/server/src/main/resources/static/app.js b/server/src/main/resources/static/app.js index 6ec92bd..3732c05 100644 --- a/server/src/main/resources/static/app.js +++ b/server/src/main/resources/static/app.js @@ -12,6 +12,7 @@ let tagChart; document.addEventListener('DOMContentLoaded', () => { initCharts(); setupNavigation(); + setupRepurchasePeriod(); // Fetch cutoff dates + initial page data fetchDataCutoff(); @@ -181,9 +182,14 @@ async function fetchTrend() { } // ============== PAGE 2: PRODUCT INSIGHTS ============== -async function fetchRepurchase() { + +// 当前选中的复购率统计周期 +let repurchaseDays = 30; + +async function fetchRepurchase(days) { + if (days !== undefined) repurchaseDays = days; try { - const response = await fetch(`${BASE_URL}/product/repurchase`); + const response = await fetch(`${BASE_URL}/product/repurchase?days=${repurchaseDays}`); if (!response.ok) return; const data = await response.json(); @@ -215,6 +221,20 @@ async function fetchRepurchase() { } catch (e) {} } +// 初始化复购率周期切换控件 +function setupRepurchasePeriod() { + const container = document.getElementById('repurchase-period'); + if (!container) return; + container.addEventListener('click', (e) => { + const btn = e.target.closest('.segment-btn'); + if (!btn) return; + container.querySelectorAll('.segment-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const days = parseInt(btn.dataset.days); + fetchRepurchase(days); + }); +} + async function fetchBasket() { try { const response = await fetch(`${BASE_URL}/product/basket`); diff --git a/server/src/main/resources/static/index.html b/server/src/main/resources/static/index.html index 1348c8a..585d9c7 100644 --- a/server/src/main/resources/static/index.html +++ b/server/src/main/resources/static/index.html @@ -4,10 +4,7 @@ 餐饮零售数据中台 | 核心引擎 - - - - + @@ -92,17 +89,22 @@
-

商品30天复购率与波士顿矩阵

+

商品复购率与波士顿矩阵

+
+ + + +
商品名称30天购买人数 30天复购人数 购买人数 复购人数 复购率 波士顿矩阵打标 波士顿矩阵打标
- - + + - + diff --git a/server/src/main/resources/static/styles.css b/server/src/main/resources/static/styles.css index d365fb6..f36aea9 100644 --- a/server/src/main/resources/static/styles.css +++ b/server/src/main/resources/static/styles.css @@ -36,7 +36,7 @@ margin: 0; padding: 0; box-sizing: border-box; - font-family: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; } @@ -302,6 +302,52 @@ body { box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08); } +/* Chart header with optional controls */ +.chart-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 1rem; +} + +.chart-header h2 { + margin: 0; +} + +/* Apple-style Segmented Control */ +.segment-control { + display: inline-flex; + background: rgba(134, 134, 139, 0.08); + border-radius: 10px; + padding: 3px; + gap: 2px; +} + +.segment-btn { + padding: 5px 16px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.25s ease; + font-family: inherit; +} + +.segment-btn:hover { + color: var(--text-primary); +} + +.segment-btn.active { + background: var(--text-primary); + color: var(--bg-base); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); +} + /* Dashboard Grid */ .dashboard-grid { display: flex; diff --git a/sql/performance_indexes.sql b/sql/performance_indexes.sql new file mode 100644 index 0000000..daeaf2d --- /dev/null +++ b/sql/performance_indexes.sql @@ -0,0 +1,33 @@ +-- ============================================= +-- 性能优化: 补全关键查询索引 +-- 执行方式: 在 MySQL 中直接执行,IF NOT EXISTS 保证幂等 +-- ============================================= + +-- 1. 订单表: pay_time (大盘总览/趋势图/订货建议 的 WHERE 条件) +-- DDL 中已定义,此处兜底确保存在 +CREATE INDEX idx_pay_time ON dwd_trade_order_detail(pay_time); +-- 如果已存在会报错,可忽略。或使用以下方式: +-- ALTER TABLE dwd_trade_order_detail ADD INDEX idx_pay_time(pay_time); + +-- 2. 订单表: status (数据截止时间查询 WHERE status = 'TRADE_SUCCESS') +ALTER TABLE dwd_trade_order_detail ADD INDEX idx_status(status); + +-- 3. 订单表: etl_update_time (客户聚合增量检测) +ALTER TABLE dwd_trade_order_detail ADD INDEX idx_etl_update_time(etl_update_time); + +-- 4. 订单表: outer_item_id (商品洞察/订货建议 GROUP BY) +ALTER TABLE dwd_trade_order_detail ADD INDEX idx_outer_item_id(outer_item_id); + +-- 5. 退款表: created_time (大盘总览退款额统计) +ALTER TABLE dwd_trade_refund_detail ADD INDEX idx_created_time(created_time); + +-- 6. 退款表: etl_update_time (增量检测) +ALTER TABLE dwd_trade_refund_detail ADD INDEX idx_etl_update_time(etl_update_time); + +-- 7. 客户表: etl_update_time (客户聚合增量检测) +ALTER TABLE dim_customer_info ADD INDEX idx_etl_update_time(etl_update_time); + +-- 验证索引: +-- SHOW INDEX FROM dwd_trade_order_detail; +-- SHOW INDEX FROM dwd_trade_refund_detail; +-- SHOW INDEX FROM dim_customer_info; diff --git a/sql/youzan_tplus1_ddl.sql b/sql/youzan_tplus1_ddl.sql index b14669b..5ab7767 100644 --- a/sql/youzan_tplus1_ddl.sql +++ b/sql/youzan_tplus1_ddl.sql @@ -42,6 +42,9 @@ CREATE TABLE `dwd_trade_order_detail` ( KEY `idx_tid` (`tid`), KEY `idx_pay_time` (`pay_time`), KEY `idx_update_time` (`update_time`), + KEY `idx_etl_update_time` (`etl_update_time`), + KEY `idx_status` (`status`), + KEY `idx_outer_item_id` (`outer_item_id`), KEY `idx_buyer` (`buyer_phone`, `yz_open_id`), KEY `idx_item_sku` (`item_id`, `sku_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易明细事实表 (T+1有赞同步)'; @@ -79,7 +82,8 @@ CREATE TABLE `dim_customer_info` ( UNIQUE KEY `uk_yz_open_id` (`yz_open_id`), UNIQUE KEY `uk_mobile` (`mobile`), -- 可空唯一: MySQL 允许多个 NULL, 有手机号则保证不重复 KEY `idx_wx_union_id` (`wx_union_id`), - KEY `idx_last_pay_time` (`last_pay_time`) + KEY `idx_last_pay_time` (`last_pay_time`), + KEY `idx_etl_update_time` (`etl_update_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一客户维度宽表 (OneID及基础RFM视角)'; -- ========================================== @@ -170,7 +174,9 @@ CREATE TABLE `dwd_trade_refund_detail` ( PRIMARY KEY (`id`), UNIQUE KEY `uk_refund_id` (`refund_id`), KEY `idx_tid` (`tid`), + KEY `idx_created_time` (`created_time`), KEY `idx_success_time` (`success_time`), + KEY `idx_etl_update_time` (`etl_update_time`), KEY `idx_yz_open_id` (`yz_open_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='售后与退款明细表';
商品名称30天购买人数 30天复购人数 购买人数 复购人数 复购率 波士顿矩阵打标 波士顿矩阵打标