基于MapReduce+Spring Boot+Vue的奶茶销售数据分析平台

前言

随着奶茶行业的快速发展,对销售数据进行深入分析变得越来越重要。本文将详细介绍一个完整的奶茶销售数据分析平台,该平台采用大数据处理技术(MapReduce)、后端开发(Spring Boot)和前端可视化(Vue.js + ECharts)实现,从数据处理到可视化展示的完整流程。

一、项目概述

1.1 项目背景

本项目基于31895条奶茶销售数据,通过多维度分析揭示不同年龄段、城市、品牌和产品类型之间的消费规律,为奶茶行业的市场策略制定、产品创新和品牌定位提供数据支持。

1.2 技术架构

原始数据(CSV) → MapReduce处理 → 结果文件存储
                                    ↓
数据可视化页面 ← Spring Boot API ← 结果数据读取

1.3 技术栈

模块 技术 版本
数据处理 Hadoop MapReduce 3.3.4
后端开发 Spring Boot 2.7.18
前端开发 Vue.js 3.x
图表库 ECharts 5.5.1
UI组件库 Element Plus 2.13.1
构建工具 Maven + Vite -

二、数据源说明

2.1 数据结构

原始数据存储在data/teaMilkDate.csv文件中,包含18个字段:

字段名 描述 示例值
order_id 订单ID 1
user_id 用户ID 100000
city 城市 武汉
city_level 城市水平 二线
brand 品牌 蜜雪冰城
product_type 产品类型 低糖茶饮
price 单价 28.42
quantity 数量 1
order_amount 订单金额 28.42
age_group 年龄段 18-24岁
year 年份 2019

三、MapReduce数据处理实现

3.1 项目结构

teaMilk-mapreduce/
├── driver/          # 驱动类
├── mapper/          # Mapper类
├── reducer/         # Reducer类
└── util/            # 工具类

3.2 核心代码实现

3.2.1 数据解析工具类

[TeaMilkParser.java](file:///f:\AAproject\teaMilkAnalysis\teaMilk-mapreduce\src\main\java\org\lipstick\util\TeaMilkParser.java)负责解析CSV行数据:

public class TeaMilkParser {
    private String[] fields;
    
    public TeaMilkParser(String line) {
        if (line.startsWith("\uFEFF")) {
            line = line.substring(1);
        }
        this.fields = parseCsvLine(line);
    }
    
    private String[] parseCsvLine(String line) {
        return line.split(",", -1);
    }
    
    public String getBrand() {
        if (fields.length >= 6) {
            return fields[4].trim();
        }
        return "";
    }
    
    public String getAgeGroup() {
        if (fields.length >= 14) {
            return fields[12].trim();
        }
        return "";
    }
    
    public String getUserId() {
        if (fields.length >= 3) {
            return fields[1].trim();
        }
        return "";
    }
}
3.2.2 Mapper实现

以年龄段品牌购买人数统计为例,[AgeBrandCountMapper.java](file:///f:\AAproject\teaMilkAnalysis\teaMilk-mapreduce\src\main\java\org\lipstick\mapper\AgeBrandCountMapper.java)实现如下:

public class AgeBrandCountMapper extends Mapper<LongWritable, Text, Text, Text> {
    private static final String SEPARATOR = "\u001F";

    @Override
    protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
        String line = new String(value.getBytes(), 0, value.getLength(), "UTF-8").trim();

        if (line.startsWith("order_id")) {
            return;
        }

        TeaMilkParser parser = new TeaMilkParser(line);

        String ageGroup = parser.getAgeGroup();
        String brand = parser.getBrand();
        String userId = parser.getUserId();

        if (ageGroup.isEmpty() || brand.isEmpty() || userId.isEmpty()) {
            return;
        }

        String outputKey = ageGroup + SEPARATOR + brand;
        String outputValue = userId;

        context.write(new Text(outputKey), new Text(outputValue));
    }
}

核心逻辑

  1. 跳过表头行
  2. 解析CSV数据,提取年龄段、品牌和用户ID
  3. 使用单元分隔符(\u001F)组合年龄段和品牌作为Key
  4. 用户ID作为Value输出
3.2.3 Reducer实现

[AgeBrandCountReducer.java](file:///f:\AAproject\teaMilkAnalysis\teaMilk-mapreduce\src\main\java\org\lipstick\reducer\AgeBrandCountReducer.java)实现如下:

public class AgeBrandCountReducer extends Reducer<Text, Text, Text, Text> {
    private static final String SEPARATOR = "\u001F";
    private static final String OUTPUT_SEPARATOR = "\t";
    
    @Override
    protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException {
        String keyStr = new String(key.getBytes(), 0, key.getLength(), "UTF-8");
        String[] dataParts = keyStr.split(SEPARATOR);
        if (dataParts.length < 2) {
            return;
        }
        
        String ageGroup = dataParts[0];
        String brand = dataParts[1];
        
        Set<String> userSet = new HashSet<>();
        for (Text value : values) {
            String valueStr = new String(value.getBytes(), 0, value.getLength(), "UTF-8");
            userSet.add(valueStr.trim());
        }
        
        int userCount = userSet.size();
        
        String outputKey = ageGroup + OUTPUT_SEPARATOR + brand;
        String outputValue = String.valueOf(userCount);
        
        context.write(new Text(outputKey), new Text(outputValue));
    }
}

核心逻辑

  1. 从Key中提取年龄段和品牌
  2. 使用Set去重,统计唯一用户数量
  3. 输出格式:年龄段\t品牌\t用户数量
3.2.4 Driver实现

[TeaMilkAnalysisDriver.java](file:///f:\AAproject\teaMilkAnalysis\teaMilk-mapreduce\src\main\java\org\lipstick\driver\TeaMilkAnalysisDriver.java)负责配置和启动MapReduce作业:

public class TeaMilkAnalysisDriver implements Tool {
    
    @Override
    public int run(String[] args) throws Exception {
        String inputPath = args.length > 0 ? args[0] : "F:/AAproject/teaMilkAnalysis/data/teaMilkDate.csv";
        String outputPath = args.length > 1 ? args[1] : "F:/AAproject/teaMilkAnalysis/output";
        
        Configuration conf = new Configuration();
        conf.set("mapreduce.framework.name", "local");
        conf.set("fs.defaultFS", "file:///");
        
        conf.set("textoutputformat.separator", ",");
        conf.set("mapreduce.output.textoutputformat.encoding", "UTF-8");
        conf.set("mapreduce.input.textinputformat.encoding", "UTF-8");
        
        Job job = Job.getInstance(conf, "Tea Milk Analysis");
        job.setJarByClass(TeaMilkAnalysisDriver.class);
        
        job.setInputFormatClass(TextInputFormat.class);
        LazyOutputFormat.setOutputFormatClass(job, TextOutputFormat.class);
        
        job.setMapperClass(TeaMilkMapper.class);
        job.setReducerClass(TeaMilkReducer.class);
        
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(Text.class);
        
        FileInputFormat.addInputPath(job, new Path(inputPath));
        FileOutputFormat.setOutputPath(job, new Path(outputPath));
        
        return job.waitForCompletion(true) ? 0 : 1;
    }
}

3.3 MapReduce作业列表

项目包含7个MapReduce作业:

作业名称 输出目录 功能描述
age_brand_count age_brand_count/ 年龄段品牌购买人数统计
age_product_count age_product_count/ 年龄段产品类型购买人数统计
brand_city_popularity brand_city_popularity/ 品牌在城市受欢迎程度统计
brand_year_orders brand_year_orders/ 品牌每年订单数量统计
brand_year_sales brand_year_sales/ 品牌每年消费总额统计
city_level_brand_sales city_level_brand_sales/ 城市水平品牌消费总额统计
city_level_purchase city_level_purchase/ 城市水平购买数量统计

四、Spring Boot后端API实现

4.1 项目结构

teaMilk-api/
├── controller/       # 控制器
├── service/          # 服务层
└── config/           # 配置类

4.2 核心代码实现

4.2.1 服务层

[AnalysisResultService.java](file:///f:\AAproject\teaMilkAnalysis\teaMilk-api\src\main\java\org\lipstick\api\service\AnalysisResultService.java)负责读取MapReduce处理结果:

@Service
public class AnalysisResultService {

    private static final String OUTPUT_BASE_PATH = "F:/AAproject/teaMilkAnalysis/output/";
    
    public static class ResultItem {
        private String field1;
        private String field2;
        private Object value;
        
        public ResultItem(String field1, String field2, Object value) {
            this.field1 = field1;
            this.field2 = field2;
            this.value = value;
        }
        
        public String getField1() { return field1; }
        public String getField2() { return field2; }
        public Object getValue() { return value; }
    }
    
    public List<ResultItem> getAgeBrandCountResult() {
        return readResultFileToList("age_brand_count/part-r-00000");
    }
    
    private List<ResultItem> readResultFileToList(String fileName) {
        List<ResultItem> result = new ArrayList<>();
        String filePath = OUTPUT_BASE_PATH + fileName;

        try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(filePath), "UTF-8"))) {
            String line;
            while ((line = reader.readLine()) != null) {
                line = line.trim();
                if (line.isEmpty()) continue;

                String[] parts = line.split("\\t");
                if (parts.length >= 3) {
                    String field1 = parts[0];
                    String field2 = parts[1];
                    String valueStr = parts[2];
                    
                    Object value;
                    try {
                        value = Double.parseDouble(valueStr);
                    } catch (NumberFormatException e) {
                        value = valueStr;
                    }
                    
                    result.add(new ResultItem(field1, field2, value));
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
            System.err.println("文件不存在: " + filePath);
        }

        return result;
    }
}

核心逻辑

  1. 定义ResultItem内部类封装结果数据
  2. 读取MapReduce输出文件(UTF-8编码)
  3. 按制表符分割行数据
  4. 自动识别数值类型并转换
4.2.2 控制器

[AnalysisResultController.java](file:///f:\AAproject\teaMilkAnalysis\teaMilk-api\src\main\java\org\lipstick\api\controller\AnalysisResultController.java)提供RESTful API接口:

@RestController
@RequestMapping("/analysis")
public class AnalysisResultController {

    @Autowired
    private AnalysisResultService analysisResultService;

    @GetMapping("/age-brand-count")
    public List<AnalysisResultService.ResultItem> getAgeBrandCount() {
        return analysisResultService.getAgeBrandCountResult();
    }

    @GetMapping("/age-product-count")
    public List<AnalysisResultService.ResultItem> getAgeProductCount() {
        return analysisResultService.getAgeProductCountResult();
    }

    @GetMapping("/brand-city-popularity")
    public List<AnalysisResultService.ResultItem> getBrandCityPopularity() {
        return analysisResultService.getBrandCityPopularityResult();
    }

    @GetMapping("/brand-year-orders")
    public List<AnalysisResultService.ResultItem> getBrandYearOrders() {
        return analysisResultService.getBrandYearOrdersResult();
    }

    @GetMapping("/brand-year-sales")
    public List<AnalysisResultService.ResultItem> getBrandYearSales() {
        return analysisResultService.getBrandYearSalesResult();
    }

    @GetMapping("/city-level-brand-sales")
    public List<AnalysisResultService.ResultItem> getCityLevelBrandSales() {
        return analysisResultService.getCityLevelBrandSalesResult();
    }

    @GetMapping("/city-level-purchase")
    public List<AnalysisResultService.ResultItem> getCityLevelPurchase() {
        return analysisResultService.getCityLevelPurchaseResult();
    }
}

4.3 API接口列表

接口路径 方法 描述
/analysis/age-brand-count GET 年龄段品牌购买人数统计
/analysis/age-product-count GET 年龄段产品类型购买人数统计
/analysis/brand-city-popularity GET 品牌在城市受欢迎程度统计
/analysis/brand-year-orders GET 品牌每年订单数量统计
/analysis/brand-year-sales GET 品牌每年消费总额统计
/analysis/city-level-brand-sales GET 城市水平品牌消费总额统计
/analysis/city-level-purchase GET 城市水平购买数量统计

五、Vue.js前端可视化实现

5.1 项目结构

teaMilk-dashboard/
├── src/
│   ├── views/          # 页面组件
│   ├── api/            # API调用
│   ├── router/         # 路由配置
│   └── App.vue         # 根组件

5.2 核心代码实现

5.2.1 API调用封装

[index.js](file:///f:\AAproject\teaMilkAnalysis\teaMilk-dashboard\src\api\index.js)封装了所有API调用:

import axios from 'axios'

const api = axios.create({
  baseURL: 'http://localhost:8080/analysis',
  timeout: 10000
})

export const analysisApi = {
  getAgeBrandCount: () => api.get('/age-brand-count'),
  getAgeProductCount: () => api.get('/age-product-count'),
  getBrandCityPopularity: () => api.get('/brand-city-popularity'),
  getBrandYearOrders: () => api.get('/brand-year-orders'),
  getBrandYearSales: () => api.get('/brand-year-sales'),
  getCityLevelBrandSales: () => api.get('/city-level-brand-sales'),
  getCityLevelPurchase: () => api.get('/city-level-purchase')
}
5.2.2 年龄段分析页面

[AgeAnalysisView.vue](file:///f:\AAproject\teaMilkAnalysis\teaMilk-dashboard\src\views\AgeAnalysisView.vue)展示年龄段品牌和产品类型购买偏好:

const renderAgeBrandChart = (data) => {
  if (!ageBrandChart.value || !data || data.length === 0) {
    return
  }
  
  const ageGroups = [...new Set(data.map(item => item.field1))].sort()
  const brands = [...new Set(data.map(item => item.field2))]
  
  const series = []
  const legendData = []
  
  brands.forEach(brand => {
    const brandData = ageGroups.map(age => {
      const item = data.find(d => d.field1 === age && d.field2 === brand)
      return item ? item.value : 0
    })
    
    series.push({
      name: brand,
      type: 'bar',
      data: brandData
    })
    
    legendData.push(brand)
  })

  const option = {
    title: {
      text: '各年龄段品牌购买人数分布',
      left: 'center'
    },
    tooltip: {
      trigger: 'item',
      axisPointer: {
        type: 'shadow'
      },
      formatter: function(params) {
        return `${params.name}年龄段<br/>${params.seriesName}: ${params.value}`
      }
    },
    legend: {
      data: legendData,
      bottom: 0,
      selectedMode: 'multiple'
    },
    xAxis: {
      type: 'category',
      data: ageGroups,
      name: '年龄段'
    },
    yAxis: {
      type: 'value',
      name: '购买人数'
    },
    series: series
  }

  ageBrandChart.value.clear()
  ageBrandChart.value.setOption(option)
}
5.2.3 品牌分析页面

[BrandAnalysisView.vue](file:///f:\AAproject\teaMilkAnalysis\teaMilk-dashboard\src\views\BrandAnalysisView.vue)展示品牌市场表现和趋势:

const renderBrandYearSalesChart = (data) => {
  if (!brandYearSalesChart.value || !data || data.length === 0) {
    return
  }
  
  const years = [...new Set(data.map(item => item.field1))].sort()
  const brands = [...new Set(data.map(item => item.field2))]

  const series = []
  const legendData = []
  
  brands.forEach(brand => {
    const brandData = years.map(year => {
      const item = data.find(d => d.field1 === year && d.field2 === brand)
      return item ? item.value : 0
    })
    
    series.push({
      name: brand,
      type: 'bar',
      data: brandData
    })
    
    legendData.push(brand)
  })

  const option = {
    title: {
      text: '品牌年度销售总额对比',
      left: 'center'
    },
    tooltip: {
      trigger: 'item',
      formatter: function(params) {
        return params.name + '<br/>' + params.marker + params.seriesName + ': ' + params.value.toFixed(2) + ' 元'
      }
    },
    legend: {
      data: legendData,
      bottom: 0,
      selectedMode: 'multiple'
    },
    xAxis: {
      type: 'category',
      data: years,
      name: '年份'
    },
    yAxis: {
      type: 'value',
      name: '销售总额(元)'
    },
    series: series
  }

  brandYearSalesChart.value.clear()
  brandYearSalesChart.value.setOption(option, true)
}

六、最终效果展示

6.1 年龄段分析

年龄段品牌购买人数统计

  • 图表类型:分组柱状图
  • X轴:年龄段(18-24岁、25-30岁、31-40岁等)
  • Y轴:购买人数
  • 系列:不同品牌(蜜雪冰城、喜茶、奈雪的茶等)
  • 交互:支持图例筛选、数据提示框

年龄段产品类型购买人数统计

  • 图表类型:折线图
  • X轴:年龄段
  • Y轴:购买人数
  • 系列:不同产品类型(低糖茶饮、果茶、奶茶等)
  • 交互:支持图例筛选、数据提示框

6.2 品牌分析

品牌年度销售总额统计

  • 图表类型:分组柱状图
  • X轴:年份(2019、2020、2021等)
  • Y轴:销售总额(元)
  • 系列:不同品牌
  • 交互:支持图例筛选、数据提示框

品牌年度订单数量统计

  • 图表类型:折线图
  • X轴:年份
  • Y轴:订单数量
  • 系列:不同品牌
  • 交互:支持图例筛选、数据提示框

品牌城市受欢迎程度统计

  • 图表类型:分组柱状图
  • X轴:城市(武汉、北京、上海等)
  • Y轴:受欢迎程度指数
  • 系列:不同品牌
  • 交互:支持图例筛选、数据提示框

6.3 城市分析

城市水平品牌消费总额统计

  • 图表类型:分组柱状图
  • X轴:城市水平(一线、二线、三线等)
  • Y轴:消费总额(元)
  • 系列:不同品牌
  • 交互:支持图例筛选、数据提示框

城市水平购买数量统计

  • 图表类型:饼图
  • 数据:各城市水平购买数量占比
  • 交互:支持扇区高亮、数据提示框

七、项目特点与创新点

7.1 技术特点

  1. 完整的大数据处理流程:从原始数据收集、MapReduce处理到前端可视化的完整流程
  2. 多维度数据分析:从年龄段、品牌、产品类型、城市等多个维度进行分析
  3. 直观的数据可视化:使用ECharts实现丰富的图表展示
  4. 前后端分离架构:采用现代前后端分离架构,提高开发效率和系统可维护性
  5. 可扩展的API设计:API接口设计简洁统一,便于扩展和集成

7.2 创新点

  1. MapReduce本地模式运行:支持在本地环境运行MapReduce作业,无需搭建Hadoop集群
  2. 中文编码处理:解决了MapReduce处理中文数据的编码问题
  3. 数据去重统计:使用Set数据结构实现用户去重,确保统计准确性
  4. 灵活的图表配置:支持图例筛选、数据提示框等多种交互方式

八、部署与运行

8.1 环境要求

  • JDK 1.8+
  • Maven 3.6.0+
  • Node.js ^20.19.0 || >=22.12.0
  • Hadoop 3.3.4(可选,用于运行MapReduce作业)

8.2 运行步骤

8.2.1 后端服务
cd teaMilk-api
mvn clean package
mvn spring-boot:run
8.2.2 前端服务
cd teaMilk-dashboard
npm install
npm run dev
8.2.3 访问应用
  • 前端应用:http://localhost:5173
  • 后端API:http://localhost:8080/analysis

九、总结

本项目通过对奶茶销售数据的深入分析,展示了大数据技术在消费行为分析中的应用。项目实现了从原始数据处理到可视化展示的完整流程,为奶茶行业的市场分析和决策提供了有力支持。

项目采用了现代的技术架构和开发方法,具有良好的可扩展性和可维护性。通过多维度的数据分析和直观的可视化展示,为用户提供了丰富的数据洞察。

未来,项目可以进一步扩展,引入实时数据处理、机器学习等技术,实现更高级的数据分析和预测功能。

十、源码地址

https://m.tb.cn/h.7rssKpB?tk=HBWsUR7v1B3


作者:[大数据基础]
日期:2026-02-01
技术栈:MapReduce + Spring Boot + Vue.js + ECharts

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐