如果你也在为Java毕业设计挑题目挑到眼花,我可以负责任地说一句:“双鲤”国画作品交易平台这个方向,值得你认真考虑。它不是一个普通的CRUD练习,而是集用户认证、商品展示、交易流转、后台管理于一体的完整全栈项目。整个系统后来正式定名为“墨韵轩——中国传统绘画艺术品数字化交易系统”,前端用户端对外还有一个品牌叫“丹青阁——水墨艺术在线流通与鉴赏服务平台”。简单说,双鲤是开发代号,墨韵轩是整套系统的官方名,丹青阁是面向C端用户的平台品牌,三个名字其实是同一个项目在不同阶段的不同叫法。
这个项目要解决的行业痛点非常明确:传统国画交易长期依赖线下画廊、拍卖行和熟人介绍,画家作品很难触达外地藏家,藏家想买一幅有品质保障的作品也缺少可信渠道。放到毕业设计语境里,它天然覆盖Spring Boot、MyBatis-Plus、MySQL、Vue、权限控制、订单状态机等一系列核心技能,而且业务闭环清晰,答辩时每个功能点都能讲出实际价值。适合基础中等、想通过一个真正完整的项目来提升工程能力的人。
我先说结论:进度正常的情况下,4到6周可以完成从建表到前后端联调。下面按需求拆解、架构设计、实操实现、踩坑排查的顺序展开,尽量少讲空话,给你可以直接抄作业的方案。
1. 项目概述与核心需求拆解
1.1 这个平台解决的行业痛点
国画交易和普通商品电商最大的区别在于:作品单价高、低频但客单价高、买家对鉴赏有强需求。所以平台不能直接照搬普通商城那套功能,必须在“鉴赏”和“可信交易”两方面做文章。
从毕业设计视角,这个题目可以拆成三层需求。用户层要能注册登录、浏览作品、按分类和画家筛选、查看作品大图与详情、收藏、加入购物车、下单、模拟支付、查看历史订单、发表评论。管理员层要能登录后台、管理画家与作品、审核上架、处理订单状态、维护分类和轮播图。系统层则要解决图片存储与访问、订单状态流转、用户权限、基础数据统计这些问题。
这三层单独看都不难,真正的难点在于把它们串成一个完整闭环。比如用户下单后,管理员要在后台看到订单并修改发货状态;画家上传的作品必须先经过管理员审核,审核通过才能在前台展示;作品一旦售出,对应的商品状态要立即变成“已售出”并从前台列表中隐藏。这种状态流转和角色权限的配合,恰恰是老师答辩时最爱追问的地方。
1.2 技术选型:为什么是Spring Boot而不是SSH
我每次带毕设都强调一句话:不要为了显得有技术含量去选一个你根本无法驾驭的技术栈。这个项目最终采用Spring Boot 2.6 + MyBatis-Plus + MySQL 8 + Vue 2 + Element UI,理由非常实际。
第一,Spring Boot已是Java领域事实上的标准。它的自动配置和起步依赖让XML配置几乎成为历史,对比十年前的SSH或经典SSM,同一个功能至少能少写一半配置。毕设时间宝贵,省下来的时间应该花在业务设计上,而不是在数据源配置文件里折腾。
第二,MyBatis-Plus能极大降低数据访问层的编码量。内置BaseMapper已经提供了大部分单表CRUD方法,分页查询也有现成插件。国画交易平台的核心表——用户表、作品表、订单表——大部分操作都是单表为主、关联查询为辅,正好符合MyBatis-Plus的适用场景。
第三,前端选Vue 2 + Element UI是因为成熟度和资料量都有保障。Element UI的表格、表单、弹窗、分页组件直接拿来用,后台管理页面几乎就是“拼装”出来的。如果你还没系统学过Vue,Vue 2的教程和Demo数量远比Vue 3多,遇到问题更容易搜到答案。
不过要提醒一句,选型也要考虑答辩老师的偏好。如果老师更偏传统服务端渲染,可以把Vue换成Thymeleaf;如果老师希望看到分布式能力,可以引入Redis做缓存或验证码存储。但就“稳妥完成并讲清楚”来说,Spring Boot + Vue前后端分离是目前最主流、参考资料最丰富、也最容易讲的组合。
1.3 项目命名演变:双鲤、墨韵轩与丹青阁的关系
很多同学拿到题目看到三个名字会一头雾水,其实这是我做项目时的一个刻意安排。双鲤是开发代号,取自古诗“客从远方来,遗我双鲤鱼”,寓意书画往来的雅意,开发阶段全程用这个代号沟通,避免过早纠结品牌名。墨韵轩是系统整体中文名,用于后端管理平台和答辩PPT封面,听起来更有文化底蕴。丹青阁是面向用户的前端服务平台品牌,对应“水墨艺术在线流通与鉴赏”的对外展示口径。
这里的核心教训是:对外可以有多个名字,但工程里必须统一。我实操时后端包名统一用com.shuangli,前端路由统一用/danqing/,文档标题统一写墨韵轩,三个名字互不干扰。如果每个名字都在代码里用一遍,包名、路由、前端目录会立刻乱成一团。后面你自己建工程时,也务必只选一个英文标识贯穿到底。
2. 系统架构与数据库设计
2.1 前后端分离的运行流程
整个系统可以划分为Vue前端、Spring Boot后端、MySQL数据库三大部分。前端负责页面渲染和交互,通过axios发送HTTP请求到后端;后端处理业务逻辑后返回JSON数据;前端再把数据渲染到页面。
具体请求链路举个例子:用户在丹青阁首页点击“工笔花鸟”分类,前端路由跳转到分类页,随后在页面created钩子里发起GET请求/api/painting/list?categoryId=2&page=1。前端通过vue.config.js里的代理把/api开头的请求转发到localhost:8080,后端PaintingController接收参数后调用Service层的分页查询,Service再通过PaintingMapper查询MySQL的painting表,最终把当前页数据和总条数封装成PageVO返回。整个过程中,前端不关心SQL细节,后端不关心页面DOM结构,双方只靠统一接口协议通信。
这种架构最大的实际好处是,管理员后台和前台展示可以分成两拨人同时开发,只要提前约定好接口格式就行。我自己带学生时也愿意让他们这样分工,效率明显更高。
这里多说一句,很多同学画架构图喜欢堆“三层架构”“MVVM”各种概念,但答辩中最受用的其实是一张“浏览器—前端工程—后端工程—数据库”的箭头流转图,再附一张清晰的核心接口列表。老师想看的是你懂不懂数据怎么流转,而不是图有多炫。你务必对着自己画的图把一次完整请求从头到尾讲一遍,能讲顺,这关基本就过了。
2.2 数据库设计:核心表的字段与关系
数据库设计是重头戏,这里直接给出我最终落地的8张核心表。
用户表user:id、username、password(BCrypt加密)、nickname、phone、avatar、role(0普通用户/1管理员/2画家)、create_time。
作品表painting:id、title、category_id、painter_name、description、price、cover_image、detail_images(JSON字符串)、status(0待审核/1已上架/2已下架/3已售出)、artist_id、create_time、view_count。
分类表category:id、name、sort。
购物车表cart_item:id、user_id、painting_id、quantity、create_time。
订单表orders:id、order_no、user_id、total_price、status(0待支付/1已支付待发货/2已发货/3已完成/4已取消)、address、receiver_name、receiver_phone、create_time、pay_time。
订单明细表order_item:id、order_id、painting_id、price、title_snapshot、painter_snapshot。
收藏表favorite:id、user_id、painting_id、create_time。
评论表comment:id、painting_id、user_id、content、parent_id、create_time。
这里有三个设计细节需要展开。
第一,订单明细里为什么要有title_snapshot和painter_snapshot?因为作品标题和画家名字可能在后续被修改,如果没有快照,历史订单上的信息就会跟着变化,这对交易平台来说是严重的合规问题。写快照是我在真实电商项目里吃过亏以后才补上的习惯,放在毕设里也是一个很好的加分点。
第二,detail_images字段建议用JSON字符串存多张图片路径。有些同学习惯单独建一张image表,不是不行,但代码量至少多一倍。对毕设规模来说,JSON字符串配合后端解析完全够用,而且前端拿到JSON直接遍历渲染也很方便。
第三,orders这个表名在MySQL里是保留字,直接建表会报错。解决办法是全程用反引号包裹,或者干脆命名为t_order。这是新人最容易踩的经典坑之一,与其后来排查,不如一开始就规避。
2.3 核心状态机:订单与作品的状态流转
交易平台最重要的不是页面好看,而是状态流转不出错。订单状态方面:用户提交订单后status为0待支付,模拟支付成功后变为1已支付待发货,管理员发货后变为2已发货,用户确认收货后变为3已完成,用户主动取消或订单超时未支付则变为4已取消。后端每次修改状态都必须做前置校验,比如待支付订单不能直接跳到已完成,必须经过“已支付、已发货、已完成”的中间步骤。
作品状态同理:画家上传作品后为0待审核,管理员后台审核通过后变成1已上架,作品售出后变成3已售出,管理员也可以手动下架变成2。前台作品列表只查询status=1,待审核和已下架内容永远不会暴露给用户。
实现上,我会给状态定义枚举或常量,然后在Service层用switch或if判断合法迁移路径。举一个具体例子:订单取消方法里,仅当当前状态是0待支付时才允许执行取消操作,如果状态已经是1,就要返回“商品已支付,无法取消”的提示。这种状态机思维是工程素养的直接体现,答辩时非常加分。
3. 实操过程与核心环节实现
3.1 环境准备与工程初始化
实操环境我建议按下面这个清单准备:JDK 1.8或17(Spring Boot 2.6用JDK8即可,3.x需要JDK17)、Maven 3.6+(或直接用IDEA内置Maven)、MySQL 8.0、Node.js 14+(对应Vue2)、Navicat或DBeaver作为数据库客户端、IDEA加VS Code作为开发工具。
工程结构上,我习惯把后端和前端放在同一个根目录下,比如shuangli-market文件夹里分两个子目录:backend和frontend。这样做的好处是整个项目一个仓库就能提交,答辩打包也不会漏文件。
后端工程初始化可以直接用Spring Initializr(start.spring.io),勾选Web、MySQL Driver依赖。注意Initializr里没有MyBatis-Plus选项,需要在pom.xml手动添加:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency>前端工程用Vue CLI创建:vue create frontend,然后安装element-ui、axios、vue-router、vuex。如果网络状况不佳,建议先配置npm镜像源,否则装依赖会等到怀疑人生。
3.2 后端实现:作品展示的分页与搜索
作品列表接口是整个前台最核心的接口,必须同时支持分类筛选、关键词搜索、排序和分页。在MyBatis-Plus里的推荐写法是用LambdaQueryWrapper构建查询条件:
LambdaQueryWrapper<Painting> wrapper = Wrappers.lambdaQuery(); wrapper.eq(Painting::getStatus, 1); // 只查已上架 if (categoryId != null) { wrapper.eq(Painting::getCategoryId, categoryId); } if (keyword != null && !keyword.isEmpty()) { wrapper.and(w -> w.like(Painting::getTitle, keyword) .or().like(Painting::getPainterName, keyword)); } // 排序:orderBy=0按最新,1价格升序,2价格降序,3浏览量降序 if (orderBy == 1) wrapper.orderByAsc(Painting::getPrice); else if (orderBy == 2) wrapper.orderByDesc(Painting::getPrice); else if (orderBy == 3) wrapper.orderByDesc(Painting::getViewCount); else wrapper.orderByDesc(Painting::getCreateTime); Page<Painting> page = paintingMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);这里有两个高频坑需要特别说明。
第一个坑是keyword判断不能省。如果前端把空的keyword参数也传过来,后端又没有判断空串,查询条件就会变成like '%',那实际上是在匹配所有记录。必须判断keyword != null && !keyword.isEmpty()。
第二个坑是OR条件的括号问题。如果不使用wrapper.and(...)嵌套,直接写wrapper.like(title).or().like(painterName),后续再拼接其他eq条件时,SQL的优先级会把OR和AND组合得乱七八糟,查出来的结果完全不对。嵌套写法可以保证OR两边的内容被括号包裹。
还有一个非常隐蔽的问题:分页插件没有注册。很多同学的selectPage方法明明写了,但执行后发现查出来的是全量数据,原因是没有配置分页拦截器。需要在启动类或配置类注册:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }没有这个拦截器,selectPage不会真正产生LIMIT语句,而是先把全表查出来再做内存分页,数据量一大就出问题。
3.3 交易流程实现:下单、支付与库存更新
下单是一个必须开启事务的操作。用户提交订单时,后端要完成四件事:创建订单主表记录、创建订单明细记录、更新作品状态为已售出、清空购物车对应条目。这四件事必须在同一个事务里,任何一步失败都要全部回滚。
核心代码思路如下:
@Transactional public OrderVO submitOrder(OrderSubmitDTO dto, Long userId) { // 1. 查询购物车选中的条目 List<CartItem> cartItems = cartItemMapper.selectList( Wrappers.lambdaQuery(CartItem.class) .eq(CartItem::getUserId, userId) .in(CartItem::getId, dto.getCartItemIds())); // 2. 计算总价 BigDecimal total = cartItems.stream() .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); // 3. 创建订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTotalPrice(total); order.setStatus(0); orderMapper.insert(order); // 4. 创建订单明细并更新作品状态 for (CartItem item : cartItems) { OrderItem orderItem = new OrderItem(); orderItem.setOrderId(order.getId()); orderItem.setPaintingId(item.getPaintingId()); orderItem.setTitleSnapshot(item.getPaintingTitle()); orderItem.setPrice(item.getPrice()); orderItemMapper.insert(orderItem); // 关键:乐观锁方式防止并发重复购买 int rows = paintingMapper.updateStatusToSold(item.getPaintingId()); if (rows == 0) { throw new RuntimeException("作品已被购买,请刷新购物车"); } } // 5. 清空购物车 cartItemMapper.deleteBatchIds( cartItems.stream().map(CartItem::getId).collect(Collectors.toList())); return new OrderVO(order); }很多人写更新作品状态时习惯先select再update,也就是先查出作品,判断状态不是已售出,然后执行update。这种做法的隐患在于并发场景:两个用户同时购买同一幅作品,可能同时读到status=1,然后都执行update,最后两个订单都创建成功,但作品只有一幅。正确做法是直接执行“UPDATE painting SET status=3 WHERE id=? AND status=1”,然后判断受影响行数是否为0,为0就说明作品已经被抢走了,立刻抛异常触发事务回滚。这是最简单的乐观锁思路,答辩时能把这个点讲清楚,老师会认为你具备真实的电商并发意识。
支付接口我通常不接真实支付,而是提供一个payOrder模拟接口,把订单状态从0改为1并记录支付时间即可。答辩时说明一句“生产环境可以替换为支付宝或微信支付SDK,这里为了演示使用模拟接口”就够了。
3.4 前端页面实现:作品展示与购物流程
前端核心页面是首页、作品列表页、作品详情页。以列表页为例,Vue组件核心结构如下:
<template> <div class="painting-list"> <el-row :gutter="16"> <el-col :span="6" v-for="item in list" :key="item.id"> <el-card :body-style="{ padding: '0px' }"> <img :src="item.coverImage" class="image" /> <div style="padding: 14px;"> <h3>{{ item.title }}</h3> <p>{{ item.painterName }} · {{ item.categoryName }}</p> <p>¥{{ item.price }}</p> <el-button type="primary" @click="goDetail(item.id)">查看详情</el-button> </div> </el-card> </el-col> </el-row> <el-pagination @current-change="loadData" :page-size="pageSize" :total="total" /> </div> </template> <script> export default { data() { return { list: [], page: 1, pageSize: 12, total: 0, categoryId: null, keyword: '' } }, created() { this.loadData() }, methods: { async loadData() { const res = await axios.get('/api/painting/list', { params: { page: this.page, pageSize: this.pageSize, categoryId: this.categoryId, keyword: this.keyword } }) this.list = res.data.records this.total = res.data.total } } } </script>联调阶段最常遇到的问题就是后端字段名和前端字段名对不上。比如后端实体属性是painterName,前端模板里写成painter_name,页面就会显示为空。解决办法是前后端统一用驼峰命名,同时在后端application.yml配置mybatis-plus.configuration.map-underscore-to-camel-case=true,这样数据库下划线字段能自动映射为驼峰属性。前后端字段映射搞清楚了,很多诡异的bug会直接消失。
购物车和下单页面的逻辑就简单一些,核心是维护一个勾选状态,结算时把选中购物车的id列表传给后端。页面用v-for渲染购物车条目,右上角购物车数字可以用vuex管理,提交订单成功后跳转到支付页面,支付完成再跳转到订单列表。
3.5 图片上传与静态资源映射
国画平台对图片的依赖非常高,每件作品封面加详情图可能接近十张。毕设阶段最简单可靠的是本地存储方案:上传接口把文件保存到服务器某个目录,然后返回可访问的URL。
后端写一个FileController,接收MultipartFile,用UUID重命名后保存到upload目录,再把路径拼成URL返回。这里最关键的坑在于,Spring Boot默认的静态资源目录并不包含upload,必须手动添加资源映射,否则文件保存成功了,浏览器一访问就是404。映射配置如下:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + System.getProperty("user.dir") + "/upload/"); } }我见过太多学生卡在这一步:图片明明上传成功,返回的URL单独打开却404,就是因为缺少这段配置。另外,上传目录一定要用绝对路径,不要用相对路径。如果使用相对路径,之后从不同目录启动项目,图片就会“丢失”。如果想做得更完善,可以把存储方案换成OSS,但毕设阶段本地存储配合上述映射已经完全够用。
4. 常见问题与排查技巧实录
4.1 前后端联调时的跨域问题
这个几乎是必现问题:前端跑在localhost:3000,后端跑在localhost:8080,浏览器同源策略会拦截所有Ajax请求。解决方案有两个主流方向。
第一种是后端配置CORS过滤器:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }第二种是前端在vue.config.js里配置devServer代理:
proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } }我个人更推荐第二种,因为代理模式下浏览器层面根本不会出现跨域,后端也不需要额外配置。但要注意,前端代理只对/api开头的请求生效,如果某些接口路径不是这个前缀,记得单独补充。联调阶段如果出现网络请求一直pending或者报CORS错误,首先检查的就是这里。
4.2 图片上传后无法访问
图片上传成功但无法访问,原因通常集中在三个方面:没有配置静态资源映射导致404;上传目录没有写权限,常见于Linux服务器部署;文件名包含中文字符导致浏览器编码问题。我的统一做法是UUID重命名文件,保留原始扩展名,例如“3f7c2a1e-xxxx.jpg”,这样既避免中文乱码,也避免重名覆盖,还能在一定程度上避免非法路径访问。
4.3 MyBatis-Plus分页失效
分页失效最常见的两个原因:一是没有注册PaginationInnerInterceptor拦截器,这个问题前面已经提过;二是前端页码起始值不一致。很多分页组件页码从1开始,但部分接口习惯从0开始,如果前后端不统一,第一页看起来正常,翻到第二页就会漏数据或重复显示。排查手段很简单:在后端接口里临时打印pageNum和pageSize,确认前端传的值是否符合预期。
4.4 事务失效的常见原因
下单逻辑里使用了@Transactional,但有时会发现异常发生时数据没有回滚。我总结出三个高频原因。
第一,方法被同类内部调用。Spring的@Transactional默认通过代理生效,同类里一个方法调用另一个带事务注解的方法,事务是不会生效的。解决办法是拆分Service,或者在调用处通过注入自身的代理来调用。
第二,异常被try-catch吞掉了。事务回滚依赖异常向上传播,如果你在方法内部把异常捕获并吞掉,事务自然感知不到。比较好的做法是捕获异常后抛出RuntimeException,或者使用编程式事务手动回滚。
第三,启动类缺少@EnableTransactionManagement注解。Spring Boot大多数情况下会自动开启事务管理,但项目结构特殊时可能不生效,显式加上这个注解没有任何坏处。
4.5 管理员数据统计的简单实现
后台需要一个数据看板展示今日订单量、总销售额、作品总数等指标。MyBatis-Plus的QueryWrapper可以直接做count和sum,数据量小的时候完全够用。比如统计有效销售额时,筛选状态为已支付、已发货、已完成的订单,然后对totalPrice求和。如果后续数据量变大,可以换成SQL聚合函数或者增加统计表,但毕设阶段不必过度设计。后台页面用Echarts展示柱状图和饼图,是视觉效果提升最快的方式。
5. 个人经验与后续建议
我带这个项目的过程中最深的体会是:毕设代码量不在多,闭环完整比功能堆砌重要得多。一个能跑通用户浏览、收藏、下单、模拟支付到后台管理全流程的国画交易平台,远比一个功能繁多但状态混乱的系统更有说服力。老师最认可的永远是清晰的订单状态机、明确的事务边界、规范的接口命名,这些工程素养比任何花哨的框架都重要。
另一个建议是把项目亮点前置整理。这套系统的“作品审核流程”“订单状态机”“库存防并发”三个点,完全可以单独做成PPT页面,比答辩时现场翻代码有效得多。前端部分,把丹青阁的视觉风格往文化电商调性上靠,让老师第一眼就觉得这个平台是认真设计过的,也是加分项。
如果你想继续扩展这个项目,后续方向包括接入真实支付、引入RabbitMQ做订单超时自动取消、用Redis缓存热门作品、给画家增加个人主页、接入在线AI鉴画服务。但要记住,这些都是锦上添花,前提是把基础闭环做扎实。
最后分享一个我自己反复强调的工作习惯:动手写代码之前,先花一个晚上把数据库表和核心接口清单完整列出来。我当年带这个项目的第一版就是表结构没想清楚,后期为了加收藏字段改了两轮表,关联接口也全部受影响,非常痛苦。希望你在做“双鲤”或者类似系统时,先把这个基础工作做扎实,后面会顺利很多。