
简介基于java、springboot、vue与mysql技术栈构建的画师约稿平台毕业设计项目内容完整附带数据库脚本和常用开发工具下载即可运行。系统面向高校学生特别是计算机相关专业可用作毕业设计、课程设计或期末大作业也可作为前后端分离开发与项目部署的学习参照。平台页面美观功能覆盖画师作品展示、约稿发布、订单管理与后台权限控制操作路径直观管理便捷具备实际应用潜力。资源包共619个文件22.63MB主要包含vue前端页面与组件、java后端业务逻辑、SQL数据库初始化脚本以及maven、navicat等工具说明和启动脚本文件类型涵盖vue、java、sql、xml、yml等项目结构清晰便于快速导入IDE运行和二次开发。目前已有80人学习适合想完整走通设计、编码、测试、部署流程并获取高分毕设参考的开发者。1. 画师约稿平台到底在做什么一个「撮合交易」系统的全栈样板拿到一个「基于 javaspringbootvuemysql 的画师约稿平台 源码数据库」的项目包大部分人第一反应是「这又是一个 CRUD 毕设」但真把它跑起来、读懂代码你会发现它的业务核心根本不是画师展示作品而是一套完整的撮合交易链路约稿人发布需求、画师接单、交付稿件、验收结算。这个领域天然适合做成前后端分离的项目Spring Boot 负责出接口、Vue 负责页面交互、MySQL 存交易数据恰好把一名后端工程师日常要碰的东西都覆盖了——登录鉴权、订单状态流转、文件上传、事务一致性。它最适合两类人一是要做毕设但不想做「图书管理系统」这种烂大街题目的学生二是想用一个小而完整的项目把全栈链路捋一遍的初级开发。这篇笔记我就按我自己做这类项目的顺序把选型理由、表结构、核心代码和踩过的坑一次讲完。2. 选型与业务建模Spring Boot Vue MySQL 为什么是约稿平台的稳妥组合2.1 从数据库到接口到页面这组技术栈各自守住哪一层「基于 javaspringbootvuemysql」这个描述看起来是把四个技术名词堆在一起其实每一层都有明确的分工缺一个都不成立。MySQL 是唯一的数据源存用户、需求、订单、站内信这些结构化数据Spring Boot 在后端把数据库表映射成实体对外暴露 REST APIVue 负责把 API 返回的 JSON 渲染成页面并管理用户操作的状态。三者串起来就是「浏览器请求 → Nginx → Vue 静态资源 → axios 调后端 → Spring Boot → MyBatis → MySQL」这条链路。我一般会给这个组合再加两个轻量组件Spring Boot 里集成 Spring Security 或者自己写拦截器做鉴权前端用 PiniaVue 3 配套或者 Vuex 存登录态。为什么不用微服务因为约稿平台的业务量级在一个单体应用里完全能覆盖拆成订单服务、用户服务、支付服务反而会让毕设答辩变得难讲——面试官和评委更在意你能否把一件事做完整而不是你会不会拆一堆空壳服务。单体 模块化分包后期真要拆也有明确边界。2.2 约稿平台的业务域不是画画功能而是订单状态机很多人会把约稿平台理解成一个「作品展示 评论点赞」的内容社区这是最大的误解。约稿的核心矛盾是交易信任约稿人付了钱怕画师跑单画师画完了怕约稿人不结尾款所以系统的核心不是画作浏览而是订单状态流转。我梳理业务域时习惯先画一张状态图不用画图工具直接在纸上写约稿人发布需求 → 需求处于「招募中」 → 画师投标 → 约稿人选定画师 → 生成订单 → 约稿人支付或平台冻结资金→ 画师开始创作 → 交付草稿 → 约稿人验收 → 通过则确认完成、资金结算给画师不通过则退回修改。在这个流程之外还有取消、申诉、超时自动关闭等分支。整张图里约稿需求是「帖子」订单是「具备法律效力的交易凭证」二者不能混在一张表里否则需求一旦进入创作阶段就无法继续被其他人投标。2.3 核心表结构user、order、attachment 怎么落 SQL理解了业务域表结构就是顺势而为。我会拆出这几张核心表表名职责关键字段user用户与画师统一身份id、username、password、role(USER/ARTIST)、avatarcommission_demand约稿需求帖子id、user_id、title、description、budget、status、deadlinebid画师投标记录id、demand_id、artist_id、price、messageorders约稿订单id、demand_id、buyer_id、artist_id、amount、status、create_timeattachment稿件交付附件id、order_id、file_url、upload_time、type(DRAFT/FINAL)transaction_log交易流水id、order_id、amount、type、status这里有一个容易踩的坑user 表究竟要不要分「用户表」和「画师表」我见过不少项目把两张表分开理由是画师有更多属性比如风格标签、社交账号。实际上更稳的做法是共用一张 user 表用 role 字段区分画师额外信息放在 profile 扩展表里。原因是约稿人和画师经常是同一个人——今天你发布需求找别人画明天你也可以接别人的单。订单表里最重要的字段是 status我用一个整型来约定0 待支付、1 创作中、2 待验收、3 已完成、4 已取消、5 退款中。为什么不用字符串因为整型在索引和比较时性能更好而且状态变迁是有限的字典表里写清楚一组枚举即可。下面是一个精简但能直接跑的建表 SQLCREATE TABLE orders ( id bigint(20) NOT NULL AUTO_INCREMENT, demand_id bigint(20) NOT NULL COMMENT 关联约稿需求ID, buyer_id bigint(20) NOT NULL COMMENT 约稿人ID, artist_id bigint(20) NOT NULL COMMENT 画师ID, amount decimal(10,2) NOT NULL COMMENT 订单金额单位元, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待支付 1创作中 2待验收 3已完成 4已取消 5退款中, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_buyer_status (buyer_id, status), KEY idx_artist_status (artist_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT约稿订单表;amount 字段用 decimal(10,2) 而不是 double这个细节我后面会专门说。联合索引 idx_buyer_status 和 idx_artist_status 是照着最频繁的查询场景建的用户查看「我发布的订单」和画师查看「我接到的订单」都能走索引而不是全表扫描。utf8mb4 字符集也是必须的因为作品名和站内信里可能有 emoji 和其他生僻字utf8 存不下。3. 后端落地Spring Boot 的登录鉴权、订单状态机与文件上传3.1 工程初始化和 Maven 依赖一个能启动的 springboot 骨架后端工程我推荐用 Maven 而不是 Gradle不是因为 Gradle 不好而是 Maven 在大多数院校和公司里是默认标准别人接手你的源码时心理门槛低。创建工程时用 Spring Initializr 生成即可依赖选上 Spring Web、MyBatis、MySQL Driver、Lombok、Validation接下来改 pom.xml。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency /dependenciesSpring Boot 版本这里我选了 2.7.x 系列——注意这是稳定版且网上教程最多遇到报错最容易搜到解决方案。不要一上来就用刚发布的 3.x很多第三方 starter 还没跟上容易把自己卡在版本兼容的坑里。逻辑说明Spring Boot 2.7 的 javax 命名空间和大部分旧资料一致你复制别人代码时不会因为 jakarta 包名替换而翻车。参数说明mybatis-spring-boot-starter 2.3.2 是配合 Spring Boot 2.x 的成熟组合如果选 3.x 反而要额外处理。jjwt 是生成和解析 token 的库后面鉴权要用。启动类没什么特别的一个标准注解即可。真正要细心的是 application.ymlserver: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/art_commission?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 20MB max-request-size: 50MB mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.artcommission.entity configuration: map-underscore-to-camel-case: true连接串里的 serverTimezoneAsia/Shanghai 和 characterEncodingutf8 是血泪经验换来的缺了它你会在部署时看到「The server time zone value」报错和中文乱码这个我放在避坑章节具体说。multipart 的两个参数控制上传大小约稿平台经常传分层稿的 PSD 文件20MB 算是起步。3.2 JWT 登录鉴权与拦截器把 token 校验写在过滤器而不是每个接口里约稿平台的接口必须区分用户身份——发布需求、投标、下单、上传附件每一步都要知道「当前操作人是谁」。常见做法是登录成功后签发一个 JWT前端每次请求在 Authorization 头里带上后端拦截器解析出 userId 放进 ThreadLocal。Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录注册接口和静态资源 if (request.getRequestURI().contains(/api/auth/)) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); return false; } try { String userId JwtUtil.parseToken(token.replace(Bearer , )); // 存入请求上下文后续 controller 里可以直接取 UserContext.set(userId); return true; } catch (Exception e) { response.setStatus(401); return false; } } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求结束必须清理 ThreadLocal否则线程池复用会串号 UserContext.clear(); } }注意两个点第一UserContext 必须在线程结束时清理因为 Tomcat 的工作线程会被复用不清除的话下一个请求会拿到上一个用户的 ID这是线上才会暴露的隐蔽 bug第二拦截器里只校验 token 有效性真正的权限判断比如只有画师才能接单放在 service 层做职责分离。建议把登录接口的 controller 单独放一个 AuthController这样拦截器逻辑里只需要排除这一个前缀不用做基于注解的复杂白名单。3.3 约稿订单状态流转用状态机替代散落的 if-else订单状态是最容易写出烂代码的地方。最常见的不良实现是在每个 service 方法里直接 update status比如「画师点击开始创作」就UPDATE orders SET status 1 WHERE id ?。看起来没毛病但一旦后续加了需求比如「只有待验收状态的订单才能提交草稿」你就得在某处补一个 status 判断而判断逻辑散落在不同 controller 里越加越乱。我习惯把状态流转收口到一个方法里Service public class OrderStateMachine { Resource private OrderMapper orderMapper; /** * 根据当前状态和动作判断是否允许流转 */ public boolean transition(Long orderId, int currentStatus, int targetStatus) { // 定义合法的流转矩阵key 是当前状态value 是允许到达的目标状态集合 MapInteger, SetInteger allowed new HashMap(); allowed.put(0, Set.of(1, 4)); // 待支付 - 创作中 / 已取消 allowed.put(1, Set.of(2, 4)); // 创作中 - 待验收 / 已取消 allowed.put(2, Set.of(3, 5)); // 待验收 - 已完成 / 退款中 allowed.put(5, Set.of(4)); // 退款中 - 已取消 Order order orderMapper.selectById(orderId); if (order null || order.getStatus() ! currentStatus) { // 并发操作时状态已被别人改掉直接拒绝避免覆盖 return false; } if (!allowed.getOrDefault(currentStatus, Set.of()).contains(targetStatus)) { // 非法流转记录日志方便排查 log.warn(非法订单状态流转: orderId{}, from{}, to{}, orderId, currentStatus, targetStatus); return false; } return orderMapper.updateStatus(orderId, targetStatus) 0; } }这样设计的好处是所有状态迁移的合法性都集中在一张 Map 里新增状态只需要改一个地方并发场景下通过「先查再 compare-and-set」的方式避免两个请求同时把订单改到不同状态。如果你觉得 Map 不够直观也可以用枚举加状态机但对这个体量的项目来说Map 的可读性和维护成本已经足够好。3.4 画稿交付与静态资源映射文件存磁盘记录存 MySQL付完款之后的核心动作是画师传稿。约稿平台的文件有个特点单个文件体积大PSD、PNG 原图但总量不大。所以不需要上 FastDFS 或者对象存储直接存服务器磁盘数据库里只存文件路径的元数据即可。这是性价比最高的方案答辩时也容易讲。先写上传接口RestController RequestMapping(/api/file) public class FileUploadController { Value(${file.upload-dir}) private String uploadDir; PostMapping(/upload) public Result upload(RequestParam(file) MultipartFile file, RequestParam(orderId) Long orderId) { if (file.isEmpty()) { return Result.error(文件不能为空); } String originalName file.getOriginalFilename(); // 用 UUID 重命名避免中文文件名和路径穿越问题 String ext originalName.substring(originalName.lastIndexOf(.)); String newName UUID.randomUUID().toString().replace(-, ) ext; // 按订单 ID 分目录一个订单一个文件夹 File dir new File(uploadDir File.separator orderId); if (!dir.exists()) { dir.mkdirs(); } try { file.transferTo(new File(dir, newName)); // 数据库记录附件元数据 Attachment attachment new Attachment(); attachment.setOrderId(orderId); attachment.setFileUrl(/files/ orderId / newName); attachment.setType(DRAFT); attachmentMapper.insert(attachment); return Result.success(attachment); } catch (IOException e) { return Result.error(上传失败请重试); } } }注意 file.transferTo 在跨平台时有个隐藏问题如果上传目录和临时目录不在同一个文件系统transferTo 会失败。稳定做法是先file.getInputStream()写入目标文件但 transferTo 在大多数场景下够用。文件写到了磁盘但浏览器要访问到还需要配置虚拟路径映射。在 Spring Boot 中实现 WebMvcConfigurerConfiguration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // /files/** 映射到磁盘真实路径 registry.addResourceHandler(/files/**) .addResourceLocations(file: uploadDir /); } }这段代码的含义是浏览器访问http://localhost:8080/files/1/abc.png时Spring Boot 直接去磁盘uploadDir/1/abc.png找文件返回。参数 uploadDir 我在配置文件里设为./upload/这样项目打成 jar 包运行时upload 目录会自动建在 jar 同级目录不会出现打包后文件写到临时目录被清除的问题。4. 前端落地Vue 的路由守卫、axios 封装与约稿页面组织4.1 工程初始化Vite 还是 Vue CLI以及目录怎么分前端工程在标题里只写了 vue但 vue 的构建工具选 Vite 还是 Vue CLI 会影响你后续每一步。我的态度新项目直接 Vite原因很简单——Vite 冷启动快、热更新快调试体验好Vue CLI 基于 Webpack遇到复杂配置时网上教程多。但如果你手里的项目包原本是 Vue CLI 的那没必要迁移能跑比什么都重要。初始化完目录组织是第一个要定的事。推荐按「功能 类型」混合划分src/ api/ # 每个接口一个文件统一封装 axios 请求 auth.js order.js upload.js assets/ components/ # 跨页面复用的组件 OrderStatusTag.vue router/ index.js store/ user.js views/ Home.vue CommissionList.vue CommissionDetail.vue OrderList.vue OrderDetail.vue PublishDemand.vue utils/ request.js # axios 实例封装 App.vue main.js这个结构对新手最友好的一点是「按页面找文件」——报错时根据 URL 路径定位到 views 下的对应文件效率高。api 目录和 views 保持同构页面要什么接口就去 api 目录找对应文件。4.2 axios 封装与开发环境代理把 token 和错误码收口axios 不封装直接在组件里用是很多前端新手项目的通病。不封装的后果是每个接口都要写一遍 token 注入逻辑每个错误都要单独处理弹窗后端一改错误码格式你就要全局搜 replacement。我通常在 utils/request.js 里做一次收口import axios from axios; import { ElMessage } from element-plus; import router from ../router; const request axios.create({ baseURL: /api, // 走 devServer 代理避免跨域 timeout: 15000 }); // 请求拦截器自动带上 token request.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer token; } return config; }); // 响应拦截器统一处理错误码和登录过期 request.interceptors.response.use( response { const res response.data; // 后端约定 code 为 0 表示成功 if (res.code ! 0) { ElMessage.error(res.message || 操作失败); return Promise.reject(new Error(res.message)); } return res.data; }, error { if (error.response?.status 401) { localStorage.removeItem(token); router.push(/login); } ElMessage.error(网络异常请稍后重试); return Promise.reject(error); } ); export default request;baseURL 写成/api是配合 Vite 的 devServer.proxy 配置开发时前端跑在 5173 端口后端跑在 8080浏览器直接请求 8080 必然跨域。正确解法不是在后端开 CORS虽然能解决但生产环境谁开谁尴尬而是在 Vite 配置里把/api代理到后端// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } });生产环境则用 Nginx 做同样的反向代理配置几乎一样。这样前后端始终同源一个 Cookie 策略、一个 CORS 配置省掉一堆麻烦。4.3 路由守卫未登录用户怎么拦在约稿大厅外有了 token 机制前端的路由守卫是为了「体验更好」——未登录用户访问需要鉴权的页面时应当被平滑引导到登录页而不是等接口返回 401 再弹错误。Vue Router 4 的 beforeEach 是标准做法const router createRouter({ ... }); // 白名单这些页面不需要登录 const whiteList [/login, /register, /commission/list]; router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (token) { // 已登录还去登录页直接踢回首页 if (to.path /login) { next(/); } else { next(); } } else { if (whiteList.includes(to.path)) { next(); } else { next(/login?redirect encodeURIComponent(to.fullPath)); } } });这里我加了一个细节把redirect参数带在登录链接上登录成功后可以跳回用户原本想访问的页面而不是一刀切跳首页。这个小功能在演示时很加分——评审顺手点了一个需要登录的页面被引导到登录页登录后自动回到刚才的页面整个过程非常顺畅。4.4 约稿功能页面的组件拆分列表、详情、发布、支付页面的核心链路是「发布需求 → 逛列表 → 看详情 → 投标/下单 → 上传稿件 → 验收」。这些页面不宜拆太碎每个页面一个 vue 文件即可但内部要按业务切成子组件。以订单详情页为例它是全流程信息最密集的页面OrderDetail.vue ├── OrderInfoPanel.vue # 订单基本信息、金额、状态 ├── DemandSummary.vue # 关联的需求原文 ├── AttachmentList.vue # 稿件交付记录支持上传/预览 ├── ActionBar.vue # 根据状态显示操作按钮如确认验收、申请退款 └── ChatBox.vue # 站内信沟通简单文本即可其中 ActionBar 是状态机的页面映射后端订单状态是 0~5前端按钮组也按状态显示。比如状态为 3已完成时只显示「查看大图」和「评价」状态为 2待验收时显示「确认完成」和「申请退款」。我的习惯是写一个 computed 根据 status 返回按钮列表const actions computed(() { const map { 0: [{ text: 去支付, type: primary, handler: goPay }], 1: [{ text: 订单详情, type: plain, handler: null }], 2: [ { text: 确认完成, type: success, handler: confirmComplete }, { text: 申请退款, type: warning, handler: applyRefund } ], 3: [], 4: [], 5: [{ text: 联系客服, type: plain, handler: contactSupport }] }; return map[props.order.status] || []; });这套写法把「后端状态」和「前端按钮」的对应关系集中在一处后期加状态改按钮只动一个文件。不要小看这个组件的价值——很多画师约稿项目的需求评审里订单状态混乱是最常被提问的点前端用一个 computed 统一映射回答时思路非常清晰。5. 避坑手册前后端分离项目最常见的 5 个埋雷点5.1 跨域请求失败前端请求能发出去响应却被浏览器拦截现象前端页面打开后登录请求在 Network 面板里显示成功了状态码 200但浏览器 console 里报CORS policy错误请求的数据拿不到。原因前后端分离部署时前端在 3000 或 5173 端口后端在 8080端口不同即跨域。浏览器发现响应头里没有Access-Control-Allow-Origin就会拦截响应。诡异的是后端往往以为请求根本没到其实到了只是浏览器不让 JS 读取。解决最稳的方案是统一走反向代理让浏览器始终只访问同源地址。开发阶段用 Vite 的 proxy生产阶段用 Nginx 转发/api/到后端。如果临时想在后端解决也可以加一个 CORS 配置类但生产环境不建议这么做——Nginx 反向代理还能顺带做 gzip 压缩和静态资源缓存一举两得。5.2 MySQL 连接串少了参数中文乱码和时区报错一起出现现象项目本地启动报The server time zone value йʱ is unrecognized或者插入中文后查询出来全是问号。原因MySQL 8.x 默认时区不是 UTCJava 驱动连接时拿不到可识别的时区而编码问题是因为连接串没声明 utf8数据库连接使用了默认的 latin1。解决连接串一次写全不要精简url: jdbc:mysql://localhost:3306/art_commission?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse参数说明useUnicodetrue 和 characterEncodingutf8 告诉驱动用什么编码传输 SQL 和结果集serverTimezone 明确指定时区避免和服务器默认时区不一致。另外建库时也要显式指定字符集CREATE DATABASE art_commission DEFAULT CHARACTER SET utf8mb4。5.3 前端打包放进 Spring Boot 后刷新 404路由模式惹的祸现象本地开发时一切正常npm run build后把 dist 目录放进 Spring Boot 的 static访问首页没问题但一旦访问/order/123这种二级页面刷新就直接 404。原因Vue Router 默认用 history 模式路由路径只是前端「伪造」的地址服务器上根本没有/order/123这个静态文件。刷新时浏览器请求了服务器服务器找不到资源就返回 404。解决最简单的改法是构建时把路由改成 hash 模式URL 会变成/order/123#/order/123刷新不会请求真实路径。但这样不美观。更专业的做法是让后端做 fallback把所有非静态资源的请求转发到 index.html。Spring Boot 里实现 WebMvcConfigurer 的 addViewControllersConfiguration public class SpaForwardConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { // 把前端路由的直跳请求转发到 index.html registry.addViewController(/{path:[^\\.]*}).setViewName(forward:/index.html); } }这个配置的含义是凡是路径里不带点即不是/files/xx.png这种静态资源的请求一律转发给 index.html让 Vue Router 自己接管。但注意/files/**的上传文件访问是真实静态资源加了上述规则要及时确认其优先级——在同一个 WebConfig 中同时存在时Spring Boot 会先走 ResourceHandler不影响。5.4 金额用 double 计算对账差一分钱的玄学问题现象订单金额 89.99 元某次参与优惠打折后变成 71.99200000000001下单时显示 71.99 看起来正常但支付回调对账时发现平台账目和第三方渠道相差一分钱。原因double 和 float 是二进制浮点数十进制小数无法精确表示。约稿平台如果涉及退款、平台抽成、画师结算四舍五入的误差会在多次运算中被放大。解决所有金额字段一律用 decimal 和 BigDecimal。数据库端用 decimal(10,2)Java 实体用 BigDecimal 而不是 Double前端计算金额也用分做单位而不用元。如果要在代码里给用户展示折扣价写法是BigDecimal original new BigDecimal(89.99); BigDecimal discount new BigDecimal(0.8); BigDecimal result original.multiply(discount).setScale(2, RoundingMode.HALF_UP);参数说明setScale(2, RoundingMode.HALF_UP) 表示保留两位小数、四舍五入。千万不要在数据库里把金额字段改成 decimal 后Java 实体还用 Double 接收——运行时照样丢精度。5.5 上传大图超时和 nginx 限流本地能跑部署就翻车现象本地开发上传一张 10MB 的 PSD 文件秒成功部署到服务器后用 Nginx 反代传一半就报 413 Request Entity Too Large 或 504 Gateway Timeout。原因Nginx 默认client_max_body_size是 1MBSpring Boot 的 multipart 配置只限制了 Tomcat 层Nginx 在更前面就把请求掐大小了。而 504 往往是因为后端处理大文件写入磁盘耗时较长超出了 Nginx 默认 60 秒的 proxy 超时。解决在 Nginx 的 server 块或 location 块中显式调大限制server { listen 80; server_name yourdomain.com; client_max_body_size 50m; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_connect_timeout 60s; proxy_read_timeout 120s; proxy_send_timeout 120s; } location /files/ { alias /home/app/upload/; expires 7d; } }这里我把前端静态资源、后端 API、上传文件三个 location 分开配是最常见的部署结构。expires 7d让画作预览图这种不太变更的资源带上强缓存约稿大厅列表加载会明显变快。注意/files/的 alias 路径要用绝对路径并且 Java 侧上传目录和 Nginx alias 目录指向同一处否则出现「上传成功但图片显示 404」的诡异现象。6. 从毕设到能演示的项目索引优化、演示数据与接口自测6.1 给订单表加对索引约稿大厅查询从全表扫描降到毫秒级前面建表时已经加了idx_buyer_status和idx_artist_status但「约稿大厅」的列表查询是按需求表的 status 和 create_time 排序的这两个查询条件容易被忽略。我会额外在 commission_demand 表加一组合索引让首页的查询走 indexALTER TABLE commission_demand ADD KEY idx_status_time (status, create_time DESC);使用EXPLAIN SELECT * FROM commission_demand WHERE status 1 ORDER BY create_time DESC验证时确认 type 列从 ALL 变成 refrows 大幅减少。这套操作在答辩时讲出来比单纯说「我建了索引」有说服力得多。6.2 预置演示数据与账号让评审不用注册就能看到完整流程演示时最尴尬的场景是评委想从约稿大厅点到详情结果你数据库空荡荡的只能现场注册账号、发布需求等半天看不到效果。我的习惯是在 SQL 脚本里预置两条演示链路一条是「已经完成的订单」——买家、画师、附件、评价都齐全方便展示订单详情页另一条是「待验收的订单」方便演示画师传稿和买家验收的操作。还要预置两个账号 tester1 / artist1密码统一 123456并在首页或登录页的 placeholder 写上提示演示时直接一键登录。这里有个容易漏掉的细节预置演示数据的创建时间不能是当前时间而是脚本里写死的相对时间比如NOW() - INTERVAL 3 DAY这样列表排序和时间显示才自然。6.3 接口自测清单用 postman 把核心链路过一遍我把「从注册到完成交易」的最小链路列成一份自测清单每次改动后跑一遍注册新用户 → 登录拿到 token → 发布约稿需求 → 另一个画师账号登录接单 → 买家侧生成订单 → 画师上传草稿 → 买家确认完成 → 查看交易流水。跑完这条链路再测一遍异常分支未登录访问订单接口返回 401、重复支付同一订单被幂等拦截、非法状态流转被拒绝。这份清单用 Postman 的 Collection 保存一个人半小时能跑完比每次都手动点页面再盯日志高效得多。我自己的习惯是把这份清单放到项目的 docs 目录里作为 README 的一部分。原因很简单——毕业设计评审老师或者接手你代码的人拿到项目第一件事就是问「怎么跑起来」你给出一份从建库、导 SQL、启动后端、启动前端到自测链路的文档体验是完全不同的。到最后你会发现做一个画师约稿平台技术难点并不是什么高深算法而是你能不能把一条撮合链路做得完整、稳定、可演示——这恰恰是工作中最值钱的能力。希望这份笔记里的选型思路、状态机设计、避坑记录能帮到你。本文还有配套的精品资源点击获取