每年到第四季度,很多商业媒体和行业协会都会启动“年度名册调研”“商业之王评选”这类活动,目的是把过去一年里在创新力、增长性、行业影响力上表现突出的企业和人物找出来。这类评选看似是一个品牌运营动作,背后却要处理大量企业申报数据、评审分数、权重计算和榜单发布逻辑。如果完全靠表格收集、人工汇总,不仅效率低,还容易出现数据口径不统一、评审过程难追溯的问题。本文就以一套“年度商业调研评选系统”为例,从数据库设计到后端接口,再到前端展示,完整拆解如何用 Spring Boot + Vue 实现一个可落地的调研评选管理平台。
这套系统可以支持企业在线申报、管理员审核、评委多维度打分、系统自动加权计算名次、最终生成名册并发布榜单。通过这套系统的开发,你不仅能掌握一个完整的全栈项目流程,还能理解调研业务里最常见的“申报—评审—计算—发布”闭环是如何通过代码实现的。无论你是刚接触 Spring Boot 的初学者,还是想找一个完整项目练手的后端开发者,这篇文章都比较合适。
1. 背景:年度商业调研的业务逻辑与技术挑战
1.1 年度名册调研是怎么运转的
先来还原一下真实的业务场景。一个“年度商业之王”名册调研活动,通常分成几个阶段:主办方发布调研通知,企业在线提交申报材料,主办方进行资格审核,然后邀请评审团从多个维度打分,再按既定权重计算总分,最后确定名册名单并对外公布。
这个流程里,最少涉及三类角色:
企业用户:填写企业基本信息、提交年度业务数据、上传申报材料。
主办方管理员:审核申报资料、配置评审维度、管理评审团、查看最终排名。
评审专家:为每个通过审核的申报企业打分,并可以填写评审意见。
从技术角度看,这就是一个典型的多角色 CRUD + 复杂业务计算的系统。和普通管理系统不同的是,评选系统对“流程状态”和“计算结果”要求很高。企业不能越过了审核阶段就直接进入评审;评审分数一旦提交,不能随意修改;最终名册一定是按照规则计算出来的,而不是人为拍脑袋填进去的。
1.2 传统调研方式的痛点
过去很多小规模评选会采用“Excel 表格 + 邮件回收”的方式。调研通知发出去之后,企业把申报表填好回传;工作人员再手动整理成一张总表,然后分发给评委打分;打分结束后再回收、汇总、计算。这个过程至少有几个非常现实的问题:
第一,数据格式不统一。有的企业把营收填成“万元”,有的填成“元”,有的甚至直接填一个文字描述,后期清洗非常痛苦。第二,版本管理混乱。企业可能回传两次文件,评委也有初版、修订版,最后到底以哪个版本为准,经常要靠人工沟通确认。第三,计算过程不够透明。人工汇总容易出错,而且一旦参赛方对结果提出疑问,主办方很难快速拿出可追溯的评分明细。
数字化改造之后,这些问题都能通过系统化的流程约束规避掉:申报表单中字段类型固定,数据存储统一;每次提交都记录时间戳;评审分数落到数据库明细表中;排名计算交给程序执行,整个链路有迹可循。
1.3 系统需要解决的核心问题
结合上述背景,我们把系统要解决的问题收敛为下面几点:
支持企业在线提交申报,并约束数据格式。
支持主办方按年份创建调研活动,配置评分维度和权重。
支持评委查看申报详情、打分、提交评审意见。
支持系统根据评分规则自动计算企业总分并生成名册。
支持最终榜单在前端页面上公开展示。
本项目的重点不在 UI 多精致,而在于把业务流程的关键节点用代码完整表达出来。理解这套逻辑之后,哪怕后续要换成别的评选场景,比如“年度产品调研”“年度技术团队评选”,也能很轻松地做迁移。
2. 技术选型与环境准备
2.1 技术栈选型
考虑到项目的学习成本以及日常开发中的普遍性,这里采用一套比较常见的组合:
后端:Spring Boot
持久层:MyBatis-Plus
数据库:MySQL
前端:Vue 3 + Element Plus
构建工具:Maven、npm
Spring Boot 是目前 Java 后端开发使用率很高的框架,用它可以快速搭建独立运行的服务;MyBatis-Plus 在 MyBatis 基础上增强了单表 CRUD 能力,能减少模板代码;Vue 3 + Element Plus 则能让我们快速开发出美观且交互完整的后台和展示页面。
这里不引入过于复杂的前后端分离脚手架,保证代码简单直接,重点还是讲清楚业务实现逻辑。
2.2 环境版本说明
本文示例以以下环境为基础:
版本需要根据你的项目实际情况调整。如果你使用的是 Spring Boot 3.x,则需要 JDK 17 以上,个别依赖的坐标也需要同步升级。本文示例思路在所有版本上都适用,但具体依赖版本号建议以你自己的工程为准。
2.3 项目结构设计
我们采用前后端分离结构,后端工程目录如下:
TEXT
复制
3
├── src/main/java/com/example/survey
4
│ ├── SurveyApplication.java
6
│ │ ├── CompanyController.java
7
│ │ ├── ReviewController.java
8
│ │ └── RankingController.java
11
│ │ ├── SurveyApply.java
12
│ │ ├── ReviewScore.java
13
│ │ └── RankingResult.java
15
│ │ ├── CompanyMapper.java
16
│ │ ├── SurveyApplyMapper.java
17
│ │ ├── ReviewScoreMapper.java
18
│ │ └── RankingResultMapper.java
20
│ │ ├── CompanyService.java
21
│ │ ├── ReviewService.java
22
│ │ └── RankingService.java
25
└── src/main/resources
前端这里我们简化处理,使用 Vite 创建 Vue 3 项目,核心页面放在 src/views 目录下。
3. 数据库设计:核心表结构
数据库是整个评选系统的地基。设计得是否合理,直接影响后续开发的复杂度。针对上述业务场景,我们拆出四张核心表:企业信息表、调研申报表、评审打分表、名册结果表。
3.1 企业信息表 company
企业基本信息独立成表,好处是同一个企业可以参与多个年度的调研,不需要重复录入基础资料。
SQL
复制
1
CREATE TABLE `company` (
2
`id` bigint (20 ) NOT NULL AUTO_INCREMENT,
3
`name` varchar (200 ) NOT NULL COMMENT '企业名称' ,
4
`industry` varchar (100 ) DEFAULT NULL COMMENT '所属行业' ,
5
`area` varchar (100 ) DEFAULT NULL COMMENT '所在地区' ,
6
`credit_code` varchar (100 ) DEFAULT NULL COMMENT '统一社会信用代码' ,
7
`introduction` text COMMENT '企业简介' ,
8
`contact_name` varchar (50 ) DEFAULT NULL COMMENT '联系人' ,
9
`contact_phone` varchar (30 ) DEFAULT NULL COMMENT '联系电话' ,
10
`status` tinyint(4 ) DEFAULT '0' COMMENT '状态:0正常 1禁用' ,
11
`create_time` datetime DEFAULT CURRENT_TIMESTAMP ,
12
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ,
14
) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COMMENT= '企业信息表' ;
表中 credit_code 可以做成唯一索引,避免企业重复注册。industry 和 area 字段在后续做数据筛选和分类统计时会非常有用。
3.2 调研申报表 survey_apply
申报表是评选业务的核心单据,记录企业在某一年度的申报数据。为什么要把申报数据和企业基础信息分开?因为一家企业的基本信息相对固定,但每年的营收、净利润、员工数这些经营指标是变化的。
SQL
复制
1
CREATE TABLE `survey_apply` (
2
`id` bigint (20 ) NOT NULL AUTO_INCREMENT,
3
`company_id` bigint (20 ) NOT NULL COMMENT '企业ID' ,
4
`survey_year` varchar (10 ) NOT NULL COMMENT '调研年度,如2026' ,
5
`report_title` varchar (200 ) DEFAULT NULL COMMENT '申报标题' ,
6
`report_content` text COMMENT '申报内容描述' ,
7
`revenue` decimal (18 ,2 ) DEFAULT NULL COMMENT '年度营收(万元)' ,
8
`net_profit` decimal (18 ,2 ) DEFAULT NULL COMMENT '年度净利润(万元)' ,
9
`employee_count` int (11 ) DEFAULT NULL COMMENT '员工人数' ,
10
`attachment_url` varchar (500 ) DEFAULT NULL COMMENT '附件路径' ,
11
`status` tinyint(4 ) DEFAULT '0' COMMENT '状态:0待审核 1审核通过 2审核驳回' ,
12
`audit_remark` varchar (500 ) DEFAULT NULL COMMENT '审核意见' ,
13
`create_time` datetime DEFAULT CURRENT_TIMESTAMP ,
14
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ,
16
KEY `idx_company_year` (`company_id`, `survey_year`)
17
) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COMMENT= '调研申报表' ;
这里把营收和净利润统一成“万元”,是在数据入口就做了规范约束。实际业务中如果企业填写的单位不统一,计算排名时就会很麻烦。所以在后端接口里,前端表单需要做单位提示,后端也需要做字段校验。
3.3 评审打分表 review_score
评审打分是评选公平性的关键。设计评分表时,我们要考虑一个申报单会被多个评委打分,而每个评委又会从不同维度打分,所以这里采用“评委 + 申报单 + 维度”的联合维度来存储一条记录。
SQL
复制
1
CREATE TABLE `review_score` (
2
`id` bigint (20 ) NOT NULL AUTO_INCREMENT,
3
`apply_id` bigint (20 ) NOT NULL COMMENT '申报单ID' ,
4
`reviewer_id` bigint (20 ) NOT NULL COMMENT '评审人员ID' ,
5
`dimension_code` varchar (50 ) NOT NULL COMMENT '评分维度编码' ,
6
`score` decimal (5 ,2 ) DEFAULT NULL COMMENT '维度得分' ,
7
`comment` varchar (500 ) DEFAULT NULL COMMENT '评审意见' ,
8
`create_time` datetime DEFAULT CURRENT_TIMESTAMP ,
9
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ,
11
UNIQUE KEY `uk_review` (`apply_id`, `reviewer_id`, `dimension_code`)
12
) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COMMENT= '评审打分表' ;
注意这里加了唯一索引 uk_review,防止同一个评委在同一个维度下对同一个企业重复打分。实际开发中,评委有可能修改打分,我们一般会通过“先删除后插入”或“按唯一索引更新”的方式处理。
维度本身可以做成配置表,这里为了控制示例规模,我们在代码中定义一个固定的维度枚举:创新力、增长性、行业影响力。
3.4 名册结果表 ranking_result
名册结果表保存最终排名结果。每个年度的榜单可以按类型拆分,比如“商业之王主榜”“行业影响力榜”等。为了简单起见,我们用 ranking_type 字段来区分。
SQL
复制
1
CREATE TABLE `ranking_result` (
2
`id` bigint (20 ) NOT NULL AUTO_INCREMENT,
3
`survey_year` varchar (10 ) NOT NULL COMMENT '调研年度' ,
4
`company_id` bigint (20 ) NOT NULL COMMENT '企业ID' ,
5
`company_name` varchar (200 ) NOT NULL COMMENT '企业名称(冗余存储)' ,
6
`rank_no` int (11 ) DEFAULT NULL COMMENT '名次' ,
7
`ranking_type` varchar (50 ) DEFAULT 'MAIN' COMMENT '榜单类型' ,
8
`total_score` decimal (10 ,2 ) DEFAULT NULL COMMENT '总分' ,
9
`publish_status` tinyint(4 ) DEFAULT '0' COMMENT '发布状态:0未发布 1已发布' ,
10
`publish_time` datetime DEFAULT NULL COMMENT '发布时间' ,
11
`create_time` datetime DEFAULT CURRENT_TIMESTAMP ,
13
KEY `idx_year_type` (`survey_year`, `ranking_type`)
14
) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COMMENT= '名册结果表' ;
为什么要把 company_name 冗余存一份?因为榜单展示时如果每次都去关联企业表,会多一次查询;而且榜单发布之后,企业名称如果被修改,理论上不应该影响已经发布的历史榜单,所以这里做了冗余。
4. 后端核心接口实现(Spring Boot)
4.1 创建项目与依赖配置
我们先创建 Spring Boot 工程,然后在 pom.xml 中加入 Web、MySQL、MyBatis-Plus、Lombok 等依赖。
XML
复制
1
<?xml version="1.0" encoding="UTF-8"?>
2
<project xmlns ="http://maven.apache.org/POM/4.0.0"
3
xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance"
4
xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" >
5
<modelVersion > 4.0.0</modelVersion >
8
<groupId > org.springframework.boot</groupId >
9
<artifactId > spring-boot-starter-parent</artifactId >
10
<version > 2.7.18</version >
14
<groupId > com.example</groupId >
15
<artifactId > wise-survey-backend</artifactId >
16
<version > 1.0.0</version >
17
<name > wise-survey-backend</name >
18
<description > 年度商业调研评选系统后端</description >
21
<java.version > 1.8</java.version >
26
<groupId > org.springframework.boot</groupId >
27
<artifactId > spring-boot-starter-web</artifactId >
31
<groupId > com.baomidou</groupId >
32
<artifactId > mybatis-plus-boot-starter</artifactId >
33
<version > 3.5.3.1</version >
37
<groupId > mysql</groupId >
38
<artifactId > mysql-connector-java</artifactId >
39
<scope > runtime</scope >
43
<groupId > org.projectlombok</groupId >
44
<artifactId > lombok</artifactId >
45
<optional > true</optional >
如果使用 Spring Boot 3.x,需要把 mysql-connector-java 换成 com.mysql:mysql-connector-j,同时 MyBatis-Plus 也需要使用支持 Spring Boot 3 的版本。
配置文件放在 src/main/resources/application.yml:
YAML
复制
6
driver-class-name: com.mysql.cj.jdbc.Driver
7
url: jdbc:mysql://localhost:3306/wise_survey?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
13
map-underscore-to-camel-case: true
14
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
17
logic-delete-field: deleted
19
logic-not-delete-value: 0
map-underscore-to-camel-case 可以让数据库的下划线字段自动映射为 Java 的驼峰属性,比如 company_id 映射为 companyId。
4.2 实体类与 Mapper
以企业实体为例,创建 entity/Company.java:
JAVA
复制
1
package com.example.survey.entity;
3
import com.baomidou.mybatisplus.annotation.IdType;
4
import com.baomidou.mybatisplus.annotation.TableId;
5
import com.baomidou.mybatisplus.annotation.TableName;
8
import java.time.LocalDateTime;
12
public class Company {
14
@TableId(type = IdType.AUTO)
19
private String industry;
23
private String creditCode;
25
private String introduction;
27
private String contactName;
29
private String contactPhone;
31
private Integer status;
33
private LocalDateTime createTime;
35
private LocalDateTime updateTime;
其他实体类的写法类似,这里就不再逐一贴出完整代码了。创建 Mapper 接口只需要继承 BaseMapper<T>,例如:
JAVA
复制
1
package com.example.survey.mapper;
3
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
import com.example.survey.entity.Company;
6
public interface CompanyMapper extends BaseMapper <Company > {
在启动类上加上 @MapperScan 告诉 MyBatis 去哪里扫描 Mapper:
JAVA
复制
1
package com.example.survey;
3
import org.mybatis.spring.annotation.MapperScan;
4
import org.springframework.boot.SpringApplication;
5
import org.springframework.boot.autoconfigure.SpringBootApplication;
8
@MapperScan("com.example.survey.mapper")
9
public class SurveyApplication {
11
public static void main (String[] args) {
12
SpringApplication.run(SurveyApplication.class, args);
4.3 企业申报接口
企业申报是整套系统的入口。正常情况下,企业用户先填写企业信息,再填写年度申报数据。这里我们为了演示方便,在同一个接口中完成两个动作:如果企业不存在,就自动创建企业;然后再创建申报单。
创建 CompanyService.java:
JAVA
复制
1
package com.example.survey.service;
3
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
import com.example.survey.entity.Company;
5
import com.example.survey.entity.SurveyApply;
6
import com.example.survey.mapper.CompanyMapper;
7
import com.example.survey.mapper.SurveyApplyMapper;
8
import org.springframework.beans.factory.annotation.Autowired;
9
import org.springframework.stereotype.Service;
10
import org.springframework.transaction.annotation.Transactional;
12
import java.time.LocalDateTime;
15
public class CompanyService {
18
private CompanyMapper companyMapper;
21
private SurveyApplyMapper surveyApplyMapper;
23
@Transactional(rollbackFor = Exception.class)
24
public Long apply (Company company, SurveyApply apply) {
26
Company exist = companyMapper.selectOne(new LambdaQueryWrapper<Company>()
27
.eq(Company::getCreditCode, company.getCreditCode()));
30
company.setCreateTime(LocalDateTime.now());
31
companyMapper.insert(company);
33
company.setId(exist.getId());
37
apply.setCompanyId(company.getId());
39
apply.setCreateTime(LocalDateTime.now());
40
surveyApplyMapper.insert(apply);
这里有一个比较重要的设计点:申报和创建企业必须放在同一个事务中。如果企业创建成功但申报单插入失败,事务回滚后,企业信息也不会残留,避免产生“只有企业没有申报单”的脏数据。
对应的 Controller:
JAVA
复制
1
package com.example.survey.controller;
3
import com.example.survey.entity.Company;
4
import com.example.survey.entity.SurveyApply;
5
import com.example.survey.service.CompanyService;
6
import org.springframework.beans.factory.annotation.Autowired;
7
import org.springframework.web.bind.annotation.*;
9
import java.util.HashMap;
13
@RequestMapping("/api/company")
14
public class CompanyController {
17
private CompanyService companyService;
19
@PostMapping("/apply")
20
public Map<String, Object> apply (@RequestBody Map<String, Object> params) {
21
Company company = new Company();
22
company.setName((String) params.get("name" ));
23
company.setIndustry((String) params.get("industry" ));
24
company.setArea((String) params.get("area" ));
25
company.setCreditCode((String) params.get("creditCode" ));
26
company.setIntroduction((String) params.get("introduction" ));
27
company.setContactName((String) params.get("contactName" ));
28
company.setContactPhone((String) params.get("contactPhone" ));
30
SurveyApply apply = new SurveyApply();
31
apply.setSurveyYear((String) params.get("surveyYear" ));
32
apply.setReportTitle((String) params.get("reportTitle" ));
33
apply.setReportContent((String) params.get("reportContent" ));
34
apply.setRevenue(new java.math.BigDecimal(params.get("revenue" ).toString()));
35
apply.setNetProfit(new java.math.BigDecimal(params.get("netProfit" ).toString()));
36
apply.setEmployeeCount(Integer.valueOf(params.get("employeeCount" ).toString()));
38
Long applyId = companyService.apply(company, apply);
40
Map<String, Object> result = new HashMap<>();
41
result.put("success" , true );
42
result.put("applyId" , applyId);
严格来说,真实的项目应该使用 DTO 来接收参数,避免 Map 方式过于灵活导致参数不可控。这里为了压缩篇幅用了 Map,但正式开发中建议定义专门的 CompanyApplyDTO,并用 @Validated 做参数校验。
4.4 评审打分接口
评审打分是评委登录系统后,对某个申报单进行打分。一个申报单下有多个维度分数,我们需要设计一个批量提交接口。这里处理逻辑采用“先删除该评委在该申报单下的历史打分,再批量插入新打分”,这样可以保证打分数据的一致性。
创建 ReviewService.java:
JAVA
复制
1
package com.example.survey.service;
3
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
import com.example.survey.entity.ReviewScore;
5
import com.example.survey.mapper.ReviewScoreMapper;
6
import org.springframework.beans.factory.annotation.Autowired;
7
import org.springframework.stereotype.Service;
8
import org.springframework.transaction.annotation.Transactional;
10
import java.util.List;
13
public class ReviewService {
16
private ReviewScoreMapper reviewScoreMapper;
18
@Transactional(rollbackFor = Exception.class)
19
public void submitScore (Long applyId, Long reviewerId, List<ReviewScore> scoreList) {
21
reviewScoreMapper.delete(new LambdaQueryWrapper<ReviewScore>()
22
.eq(ReviewScore::getApplyId, applyId)
23
.eq(ReviewScore::getReviewerId, reviewerId));
26
for (ReviewScore score : scoreList) {
27
score.setApplyId(applyId);
28
score.setReviewerId(reviewerId);
29
score.setCreateTime(java.time.LocalDateTime.now());
30
reviewScoreMapper.insert(score);
这个接口虽然逻辑简单,但有一个容易被忽视的问题:如果 scoreList 为空,我们需要让接口直接返回,而不是删除历史数据之后不插入任何记录。实际开发中,可以在删除前做一次判断,如果传过来的打分列表为空,直接抛出业务异常。
评审维度的分数建议在接口层做范围校验,比如“创新力”满分 100 分,最小值 0,不能让评委提交一个 150 分。这里给出一个简单的校验片段:
JAVA
复制
1
for (ReviewScore score : scoreList) {
2
if (score.getScore() == null || score.getScore() < 0 || score.getScore() > 100 ) {
3
throw new IllegalArgumentException("评分必须在0到100之间" );
4.5 名册生成与榜单查询
名册生成是这个系统里最核心的计算逻辑。一笔申报单的总分计算,需要把所有评委在三个维度上的打分聚合起来。为了避免复杂 SQL 写起来难以理解,我们这里用 Java 代码来处理聚合逻辑:先查询所有申报单,再逐单查询评分明细,加权汇总后排序。
创建 RankingService.java:
JAVA
复制
1
package com.example.survey.service;
3
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
import com.example.survey.entity.RankingResult;
5
import com.example.survey.entity.ReviewScore;
6
import com.example.survey.entity.SurveyApply;
7
import com.example.survey.mapper.RankingResultMapper;
8
import com.example.survey.mapper.ReviewScoreMapper;
9
import com.example.survey.mapper.SurveyApplyMapper;
10
import org.springframework.beans.factory.annotation.Autowired;
11
import org.springframework.stereotype.Service;
13
import java.math.BigDecimal;
14
import java.math.RoundingMode;
16
import java.util.stream.Collectors;
19
public class RankingService {
22
private static final Map<String, Double> WEIGHT_MAP = new HashMap<>();
25
WEIGHT_MAP.put("INNOVATION" , 0.4 );
26
WEIGHT_MAP.put("GROWTH" , 0.35 );
27
WEIGHT_MAP.put("INFLUENCE" , 0.25 );
31
private SurveyApplyMapper surveyApplyMapper;
34
private ReviewScoreMapper reviewScoreMapper;
37
private RankingResultMapper rankingResultMapper;
39
public void generateRanking (String surveyYear, String rankingType) {
41
List<SurveyApply> applyList = surveyApplyMapper.selectList(
42
new LambdaQueryWrapper<SurveyApply>()
43
.eq(SurveyApply::getSurveyYear, surveyYear)
44
.eq(SurveyApply::getStatus, 1 ));
46
List<RankingDto> rankingList = new ArrayList<>();
49
for (SurveyApply apply : applyList) {
50
List<ReviewScore> scores = reviewScoreMapper.selectList(
51
new LambdaQueryWrapper<ReviewScore>()
52
.eq(ReviewScore::getApplyId, apply.getId()));
54
if (scores.isEmpty()) {
59
Map<String, List<ReviewScore>> grouped = scores.stream()
60
.collect(Collectors.groupingBy(ReviewScore::getDimensionCode));
62
BigDecimal totalScore = BigDecimal.ZERO;
63
for (Map.Entry<String, List<ReviewScore>> entry : grouped.entrySet()) {
64
BigDecimal dimensionAvg = entry.getValue().stream()
65
.map(ReviewScore::getScore)
66
.reduce(BigDecimal.ZERO, BigDecimal::add)
67
.divide(BigDecimal.valueOf(entry.getValue().size()), 2 , RoundingMode.HALF_UP);
69
Double weight = WEIGHT_MAP.getOrDefault(entry.getKey(), 0.0 );
70
totalScore = totalScore.add(dimensionAvg.multiply(BigDecimal.valueOf(weight)));
73
RankingDto dto = new RankingDto();
74
dto.setApplyId(apply.getId());
75
dto.setCompanyId(apply.getCompanyId());
76
dto.setTotalScore(totalScore.setScale(2 , RoundingMode.HALF_UP));
81
rankingList.sort((a, b) -> b.getTotalScore().compareTo(a.getTotalScore()));
85
rankingResultMapper.delete(new LambdaQueryWrapper<RankingResult>()
86
.eq(RankingResult::getSurveyYear, surveyYear)
87
.eq(RankingResult::getRankingType, rankingType));
90
for (RankingDto dto : rankingList) {
91
RankingResult result = new RankingResult();
92
result.setSurveyYear(surveyYear);
93
result.setCompanyId(dto.getCompanyId());
94
result.setCompanyName(getCompanyName(dto.getCompanyId()));
95
result.setRankNo(rank++);
96
result.setRankingType(rankingType);
97
result.setTotalScore(dto.getTotalScore());
98
result.setPublishStatus(0 );
99
rankingResultMapper.insert(result);
103
private String getCompanyName (Long companyId) {
在上面的示例里,getCompanyName 方法留了空实现。真实项目中,建议在生成名册前先批量查询企业信息,放进一个 Map<Long, String> 中,避免循环单条查询数据库造成 N+1 问题。这一点在实际项目中特别重要,因为榜单可能涉及几十甚至上百家企业,逐条查会有明显性能损耗。
名册生成完成后,榜单查询就非常简单了。只需要按年度和榜单类型查询,并按照 rank_no 升序返回。
5. 前端页面实现(Vue 3 简版)
5.1 榜单展示页面
前端我们不追求完整后台管理系统,而是先把两个核心场景打通:一个是评委打分页面,一个是名册榜单展示页面。
先看榜单页面。创建一个 src/views/RankingPage.vue:
VUE
复制
2
<div class="ranking-page">
3
<h2>{{ surveyYear }} 年度商业之王名册</h2>
4
<el-table :data="rankingList" stripe border>
5
<el-table-column prop="rankNo" label="名次" width="80" />
6
<el-table-column prop="companyName" label="企业名称" />
7
<el-table-column prop="totalScore" label="总分" width="120" />
13
import { ref, onMounted } from 'vue'
14
import axios from 'axios'
16
const surveyYear = ref('2026')
17
const rankingList = ref([])
23
async function fetchRanking() {
24
const { data } = await axios.get('/api/ranking/list', {
25
params: { surveyYear: surveyYear.value, rankingType: 'MAIN' }
27
rankingList.value = data
由于前后端端口不同,开发环境下需要在 Vite 中配置代理。在 vite.config.js 中加入:
JS
复制
1
import { defineConfig } from 'vite'
2
import vue from '@vitejs/plugin-vue'
4
export default defineConfig({
10
target : 'http://localhost:8080' ,
这样前端请求 /api/ranking/list 时,Vite 会把请求转发到后端 8080 端口,避免出现跨域问题。
5.2 评审打分页面
评委打分页面思路:评委选择一个待评申报单,页面展示申报的基本信息,下方针对多个评分维度输入分数,点击提交后调用后端接口。
VUE
复制
4
<el-form label-width="100px">
5
<el-form-item label="申报标题">
6
<el-input v-model="apply.reportTitle" disabled />
8
<el-form-item label="创新力">
9
<el-input-number v-model="scoreMap.INNOVATION" :min="0" :max="100" />
11
<el-form-item label="增长性">
12
<el-input-number v-model="scoreMap.GROWTH" :min="0" :max="100" />
14
<el-form-item label="行业影响力">
15
<el-input-number v-model="scoreMap.INFLUENCE" :min="0" :max="100" />
17
<el-button type="primary" @click="submitScore">提交打分</el-button>
23
import { reactive, ref } from 'vue'
24
import axios from 'axios'
27
reportTitle: 'XX企业2026年度申报材料'
30
const scoreMap = reactive({
36
async function submitScore() {
38
{ dimensionCode: 'INNOVATION', score: scoreMap.INNOVATION },
39
{ dimensionCode: 'GROWTH', score: scoreMap.GROWTH },
40
{ dimensionCode: 'INFLUENCE', score: scoreMap.INFLUENCE }
42
await axios.post('/api/review/submit', {
这只是一个演示页面。真正的业务中,评审专家需要先查看企业申报附件、财务报表等内容,页面交互会更复杂,但核心提交逻辑是一样的。
6. 系统运行与验证
6.1 初始化数据库
先在 MySQL 中创建数据库,并执行前面给出的建表 SQL:
BASH
复制
3
CREATE DATABASE wise_survey DEFAULT CHARACTER SET utf8mb4;
5
SOURCE /path/to/init.sql;
6.2 启动后端服务
在 wise-survey-backend 目录下执行:
看到类似下面的日志,说明启动成功:
TEXT
复制
1
Tomcat started on port(s): 8080 (http)
2
Started SurveyApplication in 3.2 seconds
6.3 调用接口验证
使用 curl 调用企业申报接口:
BASH
复制
1
curl -X POST http://localhost:8080/api/company/apply \
2
-H "Content-Type: application/json" \
7
"creditCode": "91110000MA000001X0",
8
"introduction": "一家专注于企业服务的科技公司",
10
"contactPhone": "13800000000",
12
"reportTitle": "2026年度商业创新实践案例",
13
"reportContent": "本公司在过去一年中完成多个核心产品升级",
返回结果:
接着调用评审打分接口:
BASH
复制
1
curl -X POST http://localhost:8080/api/review/submit \
2
-H "Content-Type: application/json" \
7
{ "dimensionCode": "INNOVATION", "score": 90 },
8
{ "dimensionCode": "GROWTH", "score": 85 },
9
{ "dimensionCode": "INFLUENCE", "score": 88 }
打完分后,调用名册生成接口,再访问榜单查询接口,应该能看到该企业出现在名册中。
6.4 前端访问
进入前端项目目录,执行:
浏览器访问 http://localhost:5173,打开榜单页面即可看到后端返回的排名数据。
7. 常见问题与排查思路
在开发这套评选系统过程中,最常遇到的问题集中表现在下面几个方面:
问题现象
常见原因
解决思路
后端启动报数据库连接失败
MySQL 未启动、连接串错误或密码不对
检查 MySQL 服务和 application.yml 配置
接口请求返回 404
Controller 路径与前端请求路径不一致
核对 @RequestMapping 和前端 URL
跨域请求被拦截
前后端端口不一致且未配置代理
配置 Vite proxy 或后端 CorsConfig
中文乱码
数据库字符集或连接参数不是 UTF-8
建库使用 utf8mb4,url 添加 characterEncoding=utf8
MyBatis-Plus 查询全部数据
条件构造器没生效
检查 LambdaQueryWrapper 是否真正执行 eq 条件
名册生成后没有数据
申报单状态不是审核通过,或没有打分数据
确认申报单 status=1 且已有评分记录
第一个常见的坑是 CORS。虽然我们在 Vite 里配置了代理,但如果你直接使用 Postman 或让前端不走代理,跨域问题就会暴露。一个简单的后端全局配置如下:
JAVA
复制
1
package com.example.survey.config;
3
import org.springframework.context.annotation.Configuration;
4
import org.springframework.web.servlet.config.annotation.CorsRegistry;
5
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
8
public class CorsConfig implements WebMvcConfigurer {
11
public void addCorsMappings (CorsRegistry registry) {
12
registry.addMapping("/**" )
13
.allowedOriginPatterns("*" )
14
.allowedMethods("GET" , "POST" , "PUT" , "DELETE" , "OPTIONS" )
第二个常见问题是评审分数的精度。数据库中使用 decimal(5,2),但 Java 端如果使用 Double 类型,很容易出现精度丢失。因此实体类中关于金额、分数的字段,建议统一使用 BigDecimal。
第三个坑是名册生成时忘记清理历史结果。如果不先删除上一轮生成的结果,直接插入新数据会导致榜单重复、名次错乱。所以生成名册的第一步,一定是先按“年度 + 榜单类型”删除旧数据。
8. 工程实践与上线建议
8.1 权限与安全
本文为了演示没有引入完整的认证授权体系,但真实上线时必须做好权限控制。企业用户只能维护自己的申报单,评委只能查看分配给自己的申报单,管理员才拥有审核和生成名册的权限。
在 Spring Boot 工程中,可以引入 Spring Security 或 Sa-Token 实现登录认证和角色授权。接口层面至少要做到三件事:身份认证、角色校验、越权访问控制。
例如,评审打分接口不能只接收 reviewerId 就信任它,应该从当前登录上下文中获取实际用户,判断该用户是否为有效评委,并且该申报单是否分配给他。否则任何普通用户都可以构造请求替别人打分。
8.2 数据一致性与幂等
申报接口要考虑重复提交问题。企业用户可能因为网络原因连续点了两次提交按钮,导致生成两条申报单。解决方案有两个方向:一是前端提交按钮做 loading 状态,禁止重复点击;二是后端在 survey_apply 表中增加一个企业申报年度唯一约束,同一个企业在同一年度只能有一条申报记录。
如果是已有系统的改造,建议在接口层做一次校验:
JAVA
复制
1
Long count = surveyApplyMapper.selectCount(new LambdaQueryWrapper<SurveyApply>()
2
.eq(SurveyApply::getCompanyId, companyId)
3
.eq(SurveyApply::getSurveyYear, year));
5
throw new RuntimeException("该企业本年度已经提交过申报" );
8.3 评分公平性算法
当评委人数较多时,直接使用所有评委的平均分,很容易受到极端分数影响。比如某位评委对某企业给出了远超其他评委的高分,就会拉高该企业的总分。这种情况下,可以考虑“去掉一个最高分和一个最低分,再计算平均分”的算法。
在 RankingService 中,可以对每个维度分组后的分数集合做一次排序,然后去掉首尾元素:
JAVA
复制
1
List<BigDecimal> sortedScores = list.stream()
2
.map(ReviewScore::getScore)
4
.collect(Collectors.toList());
6
if (sortedScores.size() > 2 ) {
7
sortedScores = sortedScores.subList(1 , sortedScores.size() - 1 );
10
BigDecimal avg = sortedScores.stream()
11
.reduce(BigDecimal.ZERO, BigDecimal::add)
12
.divide(BigDecimal.valueOf(sortedScores.size()), 2 , RoundingMode.HALF_UP);
这种去极值平均法在各类评审系统中很常见,能有效抑制个别异常评分对结果的影响。
8.4 生产环境建议
上线之前,建议从下面几个角度再检查一遍系统:
配置管理:数据库密码、密钥等信息不要明文写在配置文件里,可以使用环境变量或配置中心。
日志记录:申报提交、审核、打分、名册生成等关键操作都要打印操作人和操作时间,便于后续审计。
数据备份:每天定时备份数据库,尤其是名册结果生成前后,必须确保可以回滚。
导出能力:榜单最终可能需要生成 PDF 或 Excel 用于线下发布,可以提前规划导出接口。
性能优化:当申报单数量较大时,名册生成中的逐单查询评分列表会产生 N+1 查询,建议改为一次性查出所有评分数据,再在内存中分组计算。
把年度调研评选系统做扎实的核心,不是堆砌多炫酷的界面,也不是追求多复杂的算法,而是保证流程严谨、数据可追溯、计算可复现。申报、审核、评审、计算、发布,每个环节都有明确的状态和记录,这套系统才真正具备上线价值。如果你正准备在公司内部搭建类似的调研平台,建议先按这篇文章把数据表和核心接口梳理一遍,再根据自己的业务规模去扩展权限、导出和消息通知功能。