
墙绘这类产品天然就吃“视觉效果”线下做展示受场地限制大客户看样来回跑订单沟通成本高。这几年接了不少类似的项目从最初帮朋友搭个人作品集页面到后面完整的B端管理加C端交易平台我个人的体会是这类垂直行业的线上化难点从来不在“交易”本身而在于怎么把“展示效果”和“交易流程”揉在一个系统里还要让不懂技术的店家自己就能维护内容。这篇文章就基于“墙绘产品展示交易平台”这个项目把前后端分离架构下从数据库设计到接口实现再到Vue3前端联调的完整过程捋一遍。如果你正要做一个带内容展示属性的交易类系统或者正在学SpringBoot Vue3这套组合可以参考这套落地思路。整个项目用的是目前国内中小型系统里非常主流的组合SpringBoot做后端服务Vue3 Element Plus做管理端和用户端界面MyBatis负责数据持久层操作MySQL存业务数据。前后端通过RESTful API通信跨域用CorsConfig统一处理鉴权走JWT。这套选型的好处是生态成熟、招人容易、出问题网上一搜一大把解决方案对于接单或者做毕业设计、企业实训项目来说踩坑成本最低。1. 项目整体设计与数据库建模1.1 墙绘平台的业务需求拆解开始写代码之前我习惯先把业务方或者自己假设的场景的真实需求列出来。墙绘产品展示交易平台核心角色有三种游客、注册用户买家、管理员店家自己。游客能浏览作品展示、查看作品详情但想下单或者收藏必须登录。注册用户除了逛能下单购买墙绘服务、查看订单状态、管理收货地址、对完成的服务进行评价。管理员需要维护作品信息因为墙绘是非标品往往需要传多张效果图、管理订单状态待支付、待施工、施工中、已完成等、管理用户、管理首页轮播图、处理分类和标签。在这个基础上还能扩展出一个“施工师傅”的角色但考虑到MVP版本的开发成本第一版通常只做这三个角色。这里有个很容易忽视的点墙绘不是标准商品客户经常是先看案例然后沟通需求再谈价格。所以系统里的“产品”更准确的叫法是“案例作品”它有两个关键状态——是否上架展示、是否可供下单。有些案例只是展示客户看了喜欢可以“咨询同款”有些则是明码标价的套餐。所以数据库设计的时候作品表要带上is_sale和price字段价格允许为00就表示“面议”。1.2 核心数据表结构与字段说明MySQL数据库的命名我习惯用下划线分隔表名用复数形式字段名全部小写。整个系统一共设计了九张核心表这里挑几张关键的讲用户表t_user字段包括id、username、passwordBCrypt加密后的密文、nickname、avatar、phone、role0管理员1普通用户、status0正常1禁用、create_time。管理员账号我直接在SQL初始化脚本里插入一条用户名admin密码用BCrypt加密避免项目一启动连登录都进不去。作品表t_work这是整个系统最核心的表。字段有id、title作品标题、cover_image封面图、images详情图用JSON数组字符串存多个图片地址用逗号或者JSON串存都可以、category_id所属分类、tags标签逗号分隔、description详细描述、designer设计师名字、size尺寸规格比如“3米x5米墙面”、price价格元为单位0表示面议、is_sale是否上架1上架0下架、sales_count虚拟销量或者真实销量、create_time、update_time。订单表t_orderid、order_no订单编号用时间戳加随机数生成、user_id、work_id下单时关联作品如果是定制需求可以关联多个作品或者不关联、total_amount、status0待支付、1待施工、2施工中、3已完成、4已取消、remark、consignee_name、consignee_phone、consignee_address收货地址快照、create_time、pay_time。注意地址一定要做快照不能通过关联查询地址表因为用户改了地址之后历史订单的配送信息不能跟着变。购物车表t_cart和管理后台用的轮播图表t_banner、分类表t_category、评价表t_comment我就不一一展开了。设计原则很简单展示相关的表字段宽一点没关系冗余设计减少联表查询交易相关的表要有快照意识金额用decimal(10,2)不用float。1.3 为什么选MyBatis而不是MyBatis-Plus这个项目用的依赖是mybatis-spring-boot-starter不是网上教程烂大街的MyBatis-Plus。可能有人会问为什么不用PlusPlus确实方便自带BaseMapper单表CRUD都不用写SQL。我这边的考虑是一是这个项目的查询大多是带条件的分页查询比如按分类筛选、按价格区间筛选、根据关键词模糊搜索这些用原生SQL反而更直观可控二是很多学习者对MyBatis的原生XML映射还不熟用这个项目练手正好把resultMap、动态SQL、一对一和一对多映射这些核心技能点补齐。如果你自己的项目时间非常赶用Plus也没问题两条路都能走通。但下面核心代码我按原生MyBatis的方式讲因为搞懂了原生写法再看Plus的封装就很容易理解。2. 后端接口设计与核心功能实现2.1 SpringBoot项目结构分层我习惯把代码按这种结构组织清晰且好扩展com.wallpainting ├── controller // 接口层只做参数接收和结果返回 ├── service // 业务逻辑层事务控制在这里 │ └── impl ├── mapper // MyBatis数据访问层接口 ├── entity // 数据库实体对应类 ├── dto // 前端传参对象和entity解耦 ├── vo // 返回给前端的视图对象 ├── config // 配置类CORS、JWT拦截器、WebMvc配置 ├── common // 统一返回结果、异常处理、常量类 ├── utils // 工具类JWT工具、文件上传工具 └── WallPaintingApplication.javaController层只负责接收和响应具体业务全部下沉到Service层Service层方法加Transactional处理事务。这个项目里事务用的最多的地方是下单操作创建订单时要同步扣减作品表的虚拟库存或者增加销量、清空购物车对应条目这两个操作必须在一个事务里否则会出现订单创建了但购物车没清掉的脏数据。2.2 统一返回结果与全局异常处理前后端分离项目接口返回的数据结构一定要统一。我这里用的是ResultT类包含三个字段code200成功500失败401未登录、msg提示信息msg的文案做企业项目时一定要按统一标准来、data业务数据。全局异常处理是必须做的。SpringBoot里用RestControllerAdvice配合ExceptionHandler捕获Service层抛出的自定义业务异常BusinessException以及参数校验异常、空指针等系统异常统一转成Result结构返回。不做这个的话前端拿到的错误信息全是Spring默认的堆栈JSON没法用。JWT登录状态校验我放在了拦截器里写一个JwtInterceptor实现HandlerInterceptor接口在preHandle里验证请求头Authorization里的token是否有效。配置类里注册拦截器时直接addPathPatterns(/api/**)然后用excludePathPatterns把登录注册、作品列表、作品详情这些公开接口放行。这样写的好处是不用每个接口手动判断是否登录。2.3 接口明细一览RESTful风格后端接口按模块划分核心接口如下模块请求方式接口路径功能说明登录注册POST/api/user/register用户注册BCrypt加密密码登录注册POST/api/user/login登录发放JWT token作品模块GET/api/work/list分页查询作品支持分类/价格/关键词筛选作品模块GET/api/work/detail/{id}作品详情返回所有图片和描述作品模块POST/api/work/save管理员新增/编辑作品作品模块POST/api/work/delete管理员删除作品购物车GET/api/cart/list当前用户购物车列表购物车POST/api/cart/add添加购物车购物车DELETE/api/cart/delete/{id}删除购物车条目订单POST/api/order/create创建订单成功则跳转支付订单GET/api/order/list订单列表按状态筛选订单POST/api/order/updateStatus管理员更新订单状态轮播图GET/api/banner/list首页轮播图列表2.4 核心代码MyBatis动态SQL实现作品条件查询作品列表页的筛选条件是动态的核心代码在Mapper的XML文件里动态SQL是MyBatis最值得掌握的能力。这里直接看关键片段select idselectWorkPage resultTypecom.wallpainting.vo.WorkVO SELECT w.id, w.title, w.cover_image, w.price, w.sales_count, w.is_sale, c.name AS category_name FROM t_work w LEFT JOIN t_category c ON w.category_id c.id where if testcategoryId ! null AND w.category_id #{categoryId} /if if testkeyword ! null and keyword ! AND (w.title LIKE CONCAT(%, #{keyword}, %) OR w.tags LIKE CONCAT(%, #{keyword}, %)) /if if testminPrice ! null AND w.price gt; #{minPrice} /if if testmaxPrice ! null AND w.price lt; #{maxPrice} /if AND w.is_sale 1 /where ORDER BY w.create_time DESC /select三处细节值得展开说第一where标签会自动去除第一个满足条件的子句前面的AND所以每个if子句里的AND都可以放心写不用担心拼出来的SQL是WHERE AND title LIKE...。第二gt;和lt;是XML里大于号和小于号的转义写法。你在XML里直接写是可以通过的但写会报XML解析错误因为和标签的尖括号冲突了。所以要么用转义要么用![CDATA[ ]]包起来。第三部分企业项目不用LIMIT做分页而是用PageHelper插件一行代码PageHelper.startPage(pageNum, pageSize)后面紧跟查询方法即可。底层原理是拦截器拦截了这条SQL自动拼接LIMIT子句再把总数查出来放进PageInfo。这个插件用起来是真的顺手推荐加上。2.5 图片上传与静态资源映射墙绘产品图是系统的门面图片上传功能必不可少。本地存储上传配置上需要注意一个坑# application.yml spring: servlet: multipart: max-file-size: 10MB max-request-size: 50MB web: resources: static-locations: classpath:/static/,file:D:/upload/上传接口做的事情其实很朴素接收MultipartFile文件用UUID.randomUUID().toString()重命名拼上原始文件的后缀名按日期存到D:/upload/2025/06/这种目录结构下返回/files/2025/06/xxx.jpg这样的访问路径给前端。注意访问路径和磁盘路径是两个东西需要加一个WebMvcConfigurer把/files/**映射到file:D:/upload/Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/files/**) .addResourceMapper(file:D:/upload/); }这样浏览器直接访问URL就能打开图片不必再写一个读取图片字节流的接口。上线部署的时候换成服务器上的绝对路径即可。有一点必须提醒一下别把图片存数据库的BLOB字段里除非你的业务量极小否则数据库会迅速膨胀备份和查询都会变慢。存路径是行业通行做法。3. Vue3前端搭建与前后端联调实战3.1 前端工程目录结构前端我用Vue3 Vite Vue Router Pinia Element Plus这套组合。Vite创建项目命令很简单npm create vitelatest wall-front -- --template vue然后按需安装依赖。相比WebpackVite在开发环境的启动速度和热更新体验好得不是一点半点墙裂推荐。前端目录结构刻意保持清爽src ├── api // axios请求统一封装按模块拆分文件 ├── assets // 静态资源 ├── components // 通用组件如分页组件、上传组件 ├── router // 路由配置带前置守卫 ├── store // Pinia状态管理存用户信息、token ├── views │ ├── admin // 管理后台页面 │ ├── front // 用户端页面 │ └── login // 登录注册页 ├── utils // 工具类token封装、日期格式化 └── App.vue3.2 Axios请求封装与跨域问题前端请求用的Axios需要统一封装主要是为了两件事一是自动在请求头带上token二是统一处理后端返回的code码。// src/utils/request.js import axios from axios import { useUserStore } from /store/user import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动携带token request.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization userStore.token } return config }) // 响应拦截器统一处理业务码 request.interceptors.response.use( response { const res response.data if (res.code 200) { return res } else if (res.code 401) { ElMessage.warning(登录已过期请重新登录) router.push(/login) return Promise.reject(new Error(unauthorized)) } else { ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) } }, error { ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } )跨域问题在开发环境通常有两种解法一种是在后端写CorsConfig全局配置类用CrossOrigin注解加在Controller类上或者实现WebMvcConfigurer的addCorsMappings方法另一种是在前端Vite的vite.config.js里配置server.proxy代理。我建议前端项目用proxy// vite.config.js server: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }开发环境用代理能规避CORS的很多怪问题因为浏览器认为所有请求都是同源的。生产环境部署时前端打包后丢给Nginx配置location /api/ { proxy_pass http://localhost:8080; }反向代理过去这样前后端完全同源甚至不需要后端开启CORS。实际项目中这个方案更稳。3.3 用户端前台页面作品画廊与作品详情用户端首页我做了四个模块顶部导航栏Logo、菜单、搜索框、登录状态、中部轮播图、作品分类筛选区、作品瀑布流卡片区域。瀑布流这里没引入第三方库直接用CSS的columns属性做多列布局然后用break-inside: avoid防止卡片被截断。效果还行主要胜在代码量少且不依赖额外组件。作品卡片组件是最常复用的组件传入work对象显示封面图、标题、价格、销量。卡片点击跳转到详情页/work/detail/:id详情页用useRoute()拿到id调用/api/work/detail/{id}接口返回数据后在页面上渲染多张展示图。真正需要动脑的地方是详情页的图片展示。墙绘作品往往有多张图展示不同角度的效果而且图片尺寸可能不一致直接在模板里渲染会导致高度参差不齐。我的处理方式是用固定比例的容器加object-fit: coverel-image stylewidth: 100%; height: 350px; border-radius: 8px; :srcwork.coverImage fitcover /fitcover意思是图片铺满容器且保持宽高比超出部分裁剪。这样不管上传的原始图是什么比例展示区域永远整齐统一。很多人第一次做图片较多的站点会忽略这个属性结果页面歪歪扭扭所以这里单独说一句。3.4 管理后台作品管理与订单状态流转管理后台的路由我单独放在/admin前缀下通过前置路由守卫控制访问权限——只有localStorage里存了role 0的用户才能访问否则重定向到登录页。前端只能做展示层拦截后端接口权限还是必须用拦截器校验不然有人手动调接口就绕过去了。作品管理页是表格加弹窗表单的结构左侧分类树右侧作品表格。用Element Plus的el-table展示作品列表操作列放“编辑”和“删除”按钮新增和编辑共用同一个弹窗组件里面是el-form包含作品基础信息、图片上传、富文本描述。图片上传组件用el-uploadaction设置为后端的上传接口地址:headers属性动态绑定token否则上传请求不会携带登录凭证接口会被拦截器拦下来。el-upload有一个特别值得注意的地方默认上传成功后附件列表里会多一条记录但如果组件处于受控模式v-model:file-list绑定的数据要自己维护。极简做法是设置:limit1和:on-success回调上传成功后在回调里把返回的图片地址赋值给表单的coverImage字段。订单管理页相对简单用el-tabs按状态分Tab展示订单列表每个订单显示基本信息操作列根据当前状态显示对应按钮待支付状态可以取消、待施工状态可以确认开工、施工中状态可以标记完成。这里一个很重要的交互细节是操作前加ElMessageBox.confirm二次确认尤其是“标记完成”这类不可逆操作避免用户手滑点到导致后续流程错乱。3.5 购物车与订单创建的前端逻辑购物车页面很常规取得列表后用el-table展示前面加el-checkbox做多选底部结算栏动态汇总所有勾选条目的总价。但这里有一个业务逻辑上的关键决策墙绘产品的订单到底要不要走购物车我的设计是——购物车保留了但下单逻辑走的是“立即购买”按钮用户点立即购买时前端把workId传给后端后端在事务里创建一个只有一条商品的订单。购物车主要是给用户“先收藏再比价”的场景用的。这个决策是基于行业经验墙绘这种低频、高客单、强决策的服务用户不会像买日用品一样一次买十件购物车的使用率很低。但做展示型平台有这个功能显得完整所以保留只是不给它承担核心订单链路。创建订单的前端逻辑核心是构造请求参数const submitOrder async () { const params { workId: selectedWork.id, price: selectedWork.price, remark: remarkText.value } const res await createOrder(params) if (res.code 200) { ElMessage.success(下单成功) router.push(/order/list) } }下单之前要判断用户是否登录如果未登录则跳到登录页并携带redirect参数登录成功后回跳。这个交互在电商系统里属于基本功但很多人第一版会忽略。4. 系统部署与常见问题排查4.1 本地启动步骤与环境要求从拉代码到本地跑通整体步骤并不复杂安装JDK 1.8及以上版本配置JAVA_HOME环境变量。安装MySQL 5.7或8.0版本执行项目里的sql/init.sql脚本创建数据库和表并插入初始数据。这里有个容易踩坑的地方MySQL 8.0的驱动类名是com.mysql.cj.jdbc.Driver5.7是com.mysql.jdbc.Driver用错驱动会直接启动报ClassNotFoundException。IDEA中导入后端代码等待Maven下载依赖。如果下载慢配置阿里云镜像仓库到settings.xml能省很多时间。修改application.yml里的数据库账号密码然后在WallPaintingApplication.java里右键Run。前端目录下执行npm install安装依赖。如果npm速度慢改用淘宝镜像源npm config set registry https://registry.npmmirror.com。npm run dev启动前端开发服务器浏览器打开http://localhost:3000。管理员初始账号admin密码123456登录之后可以跳转到管理后台。4.2 MyBatis常见问题排查实录项目从零到跑通最容易出问题的地方就是MyBatis和数据库交互的环节。我把遇到过的高频问题整理一下问题一启动报错Invalid bound statement (not found)这个报错的意思是Mapper接口找到了但对应的XML文件没被加载。90%的原因是XML文件没有放在resources/mapper目录下或者application.yml里没有配置MyBatis的mapper-locations路径。mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.wallpainting.entity还有一个隐蔽原因IDEA默认不会把src/main/java目录下的XML文件打包到target所以如果你为了图方便把XML和Mapper接口放在同一个包下要手动在pom.xml或build配置里声明resources标签把XML文件包含进构建产物。问题二查询结果全是null搞了半小时发现字段映射对不上就是因为数据库表字段是下划线命名create_time实体类是驼峰命名createTimeMyBatis默认不会自动转换。两种解法要么在SQL里写别名SELECT create_time AS createTime要么全局开启驼峰映射mybatis: configuration: map-underscore-to-camel-case: true接手的项目多了之后这两种方式我都用过全局开启驼峰映射最省事但前提是团队规范统一大家都用标准的下划线命名数据库字段。问题三前端请求接口报403 Forbidden后端拦截器放行的路径没写对。比如你前端请求路径是/api/work/list拦截器拦截的是/api/**但excludePathPatterns写成/work/list少了/api前缀肯定配不上。排查思路就是打开浏览器F12看Network面板里请求的完整URL再去对拦截器配置的路径规则两者前缀必须完全一致。问题四刷新页面404Vue3用了createWebHistory模式History路由前端启动没问题但刷新页面时如果后端没有做处理会报404。这是因为刷新的时候浏览器向服务器请求了真实的URL路径但服务器上并没有这个文件。开发模式下Vite的devServer会自动处理但生产部署到Nginx时必须在Nginx配置里加上location / { try_files $uri $uri/ /index.html; }所有前端路由都Fallback到index.html由Vue Router内部自己匹配刷新404问题迎刃而解。4.3 带货级的性能优化与扩展建议项目跑通只是第一步真正上线前还能做几项优化MySQL连接池配置。默认的HikariCP已经很好用但参数可以再调细。maximum-pool-size默认10对这样一个中小型平台够用但可以调大到20提升并发能力connection-timeout默认30000毫秒建议设成6000防止数据库连接假死导致接口长时间阻塞。首页加Redis缓存。作品列表首页是访问量最大的接口完全可以缓存起来。把热点数据放到Redis里设置expireTime为10分钟接口优先查缓存缓存没有再查数据库再回填能减轻MySQL很大压力。这样做的最佳时机是项目跑通后马上加不要等上线了流量大了再加因为代码一旦进了业务逻辑中间层改造起来牵一发动全身。图片走CDN或者对象存储OSS。本地存储适合项目初期的开发环境真正上线的时候图片量大、带宽占用高本地磁盘方案很快会遇到瓶颈。替换方案是把上传逻辑改成传对象存储本质上只是把文件从本地IO改成了云SDK调用接口签名不变前端不用改但要预留这个替换的抽象接口。百度统计或友盟统计。给用户端首页加上访问埋点PV、UV、下单转化率这些数据比什么功能都重要。你后续调首页轮播图、调作品排序策略全靠这一层数据做依据不然就是拍脑袋。扩展施工师傅入驻和后台报价功能。前面提到过这个系统的下一个迭代方向是让施工师傅自己入驻用户提交需求订单后由师傅在后台报价。这样平台自动展示和交易流畅性就都有了整个商业闭环更完整。5. 项目体验与技术复盘整个项目从数据库设计到前后端联调完成我花了一周左右的业余时间。这里说几个真实的技术体会和踩坑总结。第一前后端分离的项目最耗时间的地方反而不是写接口而是接口字段的对接。前端需要的字段往往比后端数据库表的字段多一层展示逻辑比如价格要格式化、状态要翻译成中文、时间要转成指定的字符串格式。这层逻辑放前端做还是后端做是第一个要统一的规范。我的习惯是后端返回原始数据日期类型、数字类型前端的展示格式化交给Vue的过滤器或者工具函数后端不掺和展示层的逻辑。这样后端接口的复用度最高前端展示灵活度也最大。第二管理后台的表单校验一定不能省。el-form的rules规则、prop属性的绑定虽然写的时候觉得繁琐但上线后被用户用脏数据恶心过几次就知道值了。墙绘作品的价格字段我的校验规则是必须大于等于0尺寸字段是必填因为客户咨询的时候第一句就是问“多大尺寸”。这些字段校验规则本质上是在帮你省后期的沟通成本。第三关于JWT过期时间的设置。很多初学者喜欢把过期时间设成7天甚至30天用户不用反复登录看起来很贴心。但实际运营中交易类平台的token过期时间不宜过长我通常设置24小时。用户超过24小时未操作强制重新登录虽然多了一步但对账号安全是负责任的。如果你做的是后台管理系统甚至可以设置2小时。安全性和体验之间要找到平衡点。第四有条件的话项目上线前做一轮简单的单元测试。不求覆盖率高至少把订单创建、作品上下架这几条核心链路测一遍。你会发现很多隐藏的问题比如下单时workId传了一个不存在的值MyBatis返回null代码里没有做判空直接空指针了。这类问题如果在开发阶段没暴露上线后就是用户的一个投诉电话。这个项目选了一个带有垂直行业属性的业务方向比起普通的增删改查系统多了一层“展示属性”也因此增加了很多值得展开讲的细节多图展示、筛选查询、订单状态流转、权限控制、图片上传。这一套走通之后再去做其他展示加交易的业务系统比如手工艺品交易、艺术画廊展示、本地生活服务预约核心架构都是可以复用的。打开IDEA先把数据库脚本跑起来然后从后端接口开始写吧遇到具体问题再看看这篇文章踩坑记录的部分你大概率会感同身受。