feat: 优化商品洞察相关代码逻辑、新增性能优化 SQL 并更新前端样式

This commit is contained in:
Peter
2026-03-23 18:01:13 +08:00
parent 6f962d67a2
commit e37e69511f
9 changed files with 263 additions and 35 deletions

View File

@@ -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<Map<String, Object>> getRepurchaseRanking() {
String sql = """
public List<Map<String, Object>> 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);
}
}

View File

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

View File

@@ -4,10 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>餐饮零售数据中台 | 核心引擎</title>
<!-- Google Fonts for Apple-like Typography -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<!-- System font stack for Apple-like Typography (no external CDN dependency) -->
<link rel="stylesheet" href="styles.css">
<!-- ECharts -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
@@ -92,17 +89,22 @@
<div class="dashboard-grid">
<div class="glass-card chart-container">
<div class="chart-header">
<h2>商品30天复购率与波士顿矩阵 <span class="info-icon" data-tooltip="每日洞察计算任务生成, 统计近30天各商品的购买/复购人数"></span></h2>
<h2>商品复购率与波士顿矩阵 <span class="info-icon" data-tooltip="每日洞察计算任务生成, 统计各商品的购买/复购人数"></span></h2>
<div class="segment-control" id="repurchase-period">
<button class="segment-btn" data-days="7">7天</button>
<button class="segment-btn" data-days="15">15天</button>
<button class="segment-btn active" data-days="30">30天</button>
</div>
</div>
<div class="table-responsive">
<table class="data-table" id="repurchase-table">
<thead>
<tr>
<th>商品名称</th>
<th>30天购买人数 <span class="info-icon info-icon-sm" data-tooltip="COUNT(DISTINCT yz_open_id), 近30天购买过该商品的不重复客户数"></span></th>
<th>30天复购人数 <span class="info-icon info-icon-sm" data-tooltip="近30天内购买该商品≥2次的客户数"></span></th>
<th id="th-buyer-count">购买人数 <span class="info-icon info-icon-sm" data-tooltip="COUNT(DISTINCT yz_open_id), 该周期内购买过该商品的不重复客户数"></span></th>
<th id="th-repurchase-count">复购人数 <span class="info-icon info-icon-sm" data-tooltip="该周期内购买该商品≥2次的客户数"></span></th>
<th>复购率 <span class="info-icon info-icon-sm" data-tooltip="= 复购人数 ÷ 购买人数 × 100%"></span></th>
<th>波士顿矩阵打标 <span class="info-icon info-icon-sm" data-tooltip="购买人数高+复购率高=核心引流爆款; 购买人数高+复购率低=体验差需淘汰; 购买人数低+复购率高=小众潜力款; 其他=长尾观测品"></span></th>
<th>波士顿矩阵打标 <span class="info-icon info-icon-sm" data-tooltip="购买人数高+复购率高=核心引流爆款; 购买人数高+复购率低=体验差需淘汰; 购买人数低+复购率高=小众潜力款; 其他=平庸款"></span></th>
</tr>
</thead>
<tbody>

View File

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