
去年接手一个基于SpringBoot和Vue的电子招投标系统开发任务客户给的原型只有十几张截图业务流程全靠开会口头描述。刚开始我也把它当成一个带权限的CRUD管理后台来做结果真做起来才发现这是典型的规则密集、状态敏感、并发要求高的企业级业务系统。整个项目做下来最深的体会是这类系统的难点不在框架使用而在业务规则向技术模型的准确翻译。这篇文章把整个实现过程、选型逻辑和真实踩过的坑梳理一遍。内容适合两种人看一是准备接类似B端管理系统的开发人员想快速理解招投标这类流程型业务的模块拆分思路二是已经写了几个SpringBootVue项目但遇到文件上传、路由刷新404、状态机一致性等具体问题时希望找到成熟解决方案的工程师。文章会尽量把每一步的“为什么这么做”也讲清楚不是只给代码。1. 业务模型先行把招投标全流程拆成可开发的状态机1.1 涉及的角色和业务范围招投标系统的业务边界其实非常宽从项目立项、招标公告发布、供应商注册认证到投标报名、标书加密上传、开标解密、专家评标、结果公示最后到合同签订每一步都可以做成独立系统。但真正接到项目时第一步不是设计表结构而是把参与角色和核心流程梳理清楚。在这个电子招投标系统里我把角色分成四类角色核心操作系统目标招标代理 / 业主创建项目、发布公告、组织开标、查看评标结果提高发布效率减少线下沟通成本供应商企业认证、投标报名、下载招标文件、上传加密标书在线完成整个投标流程不跑现场评标专家在线审阅标书、按评分表打分、填写评审意见打破地域限制评审过程全程留痕监管 / 管理员专家抽取审核、系统监控、操作日志审计保障流程合规事后可追溯业务流程相对标准发布公告 → 供应商报名 → 投标截止 → 开标解密 → 专家评标 → 结果公示。每一个步骤都会产生状态变更而状态变更正是整个系统最容易出错的地方。这里提一个特别容易被忽视的设计决策一个“招标项目”下面通常有多个“标段”比如一个道路工程项目拆成三个施工标段。所以状态机不应该放在项目层级而应该放在标段层级。项目状态只是标段状态的聚合展示。我当时在这个点上犹豫了很久最后选择以标段为最小状态管理单元后面前端页面、后端接口、定时任务全都围绕标段来写代码清晰度和扩展性都好了不少。1.2 标段状态机与核心业务规则标段状态我设计了这样一组流转草稿 → 已发布报名中 → 报名截止 → 投标中 → 投标截止 → 开标中 → 评标中 → 公示中 → 已完成 → 已取消任何非终态都可取消状态看似简单但每个状态背后都有严格的业务规则。比如只有“已发布”状态才能报名报名时间不能晚于投标截止时间“投标中”可以上传和撤回标书但撤回操作必须留痕不能静默删除记录“投标截止”一到系统要自动拒绝所有上传请求接口必须二次校验截止时间不能只靠前端禁用按钮“开标中”才能触发解密解密失败要有明确的失败原因分类“评标中”期间标书文件只能由本项目评标专家访问其他人无权查看。这些规则如果散落在Controller和Service里后期维护会非常痛苦。我在项目里单独抽了一个SectionStateMachine组件所有状态变更必须走同一个入口不允许在业务代码里直接写section.setStatus(xxx)。这样做的直接好处是状态变更前可以做统一校验、统一记录日志、统一触发通知事件后来遇到定时任务和手动操作并发的问题时这个组件成了修复的主战场。2. 技术路线定格SpringBoot Vue组合的取舍与工程结构2.1 为什么不选前后端一体也不上微服务首先是架构选择的两个判断。第一个判断是前后端分离。招投标系统虽然是典型的B端管理平台但前端交互复杂度并不低公告要富文本编辑开标需要实时状态刷新专家评分表要动态生成供应商报名要分步骤填写大量资质信息。用传统的Thymeleaf服务端渲染方案做这套东西页面状态管理和组件复用会很吃力。Vue 3 Element Plus的组件化能力明显更合适前后端并行开发也能把项目周期压缩不少。第二个判断是不上微服务。很多团队看到“企业级”三个字就想上Spring Cloud但我在这个项目里坚决没用。招投标系统的数据量级远没有到需要拆分服务的程度核心矛盾是业务规则的正确性和流程的可追溯性这类系统对性能的要求反而不高。单体应用打包出来一百多MB的Jar包一台服务器完全跑得动。一旦拆分微服务接口鉴权、分布式事务、调用链追踪全都要跟上来对一个中小型交付项目来说复杂度会翻好几倍收益却很小。我的经验是系统设计永远要贴着业务规模走不要给客户造他们不需要的架构。2.2 技术栈版本与选型理由这里列出最终定稿的技术栈层次选型理由后端框架Spring Boot 2.7.x稳定、部署简单、社区资料最全规避Spring Boot 3的包迁移成本JDK1.8客户服务器环境老旧兼容性优先ORMMyBatis-Plus 3.5.x单表CRUD不用写SQL复杂统计用XML自定义数据库MySQL 8.0.xutf8mb4字符集满足中文存储和索引长度要求缓存Redis 6.x验证码、JWT黑名单、高频状态缓存安全认证Sa-Token / JWT比Spring Security配置量小适合快速交付的项目前端Vue 3 Vite Pinia Element Plus组合式API开发效率高组件生态成熟富文本wangEditor公告编辑够用文档体积可控视频流vue-video-player hls.js开标直播用HLS流格式播放关于安全框架多说两句如果团队有Spring Security经验用它当然没问题但如果是中小型项目且交付压力大Sa-Token这类框架开箱即用的登录、权限注解和Redis集成能省掉一半开发量。项目落地的本质是用合适的工具换时间不要为了简历好看而选择重框架。2.3 前后端工程结构设计后端目录按业务域组织而不是按技术层堆叠com.tender ├─ controller │ ├─ project/ # 招标项目管理 │ ├─ signup/ # 投标报名 │ ├─ opening/ # 开标 │ └─ evaluate/ # 评标 ├─ service │ ├─ impl/ │ └─ state_machine/ # 标段状态机不放在业务service里 ├─ mapper ├─ entity ├─ common │ ├─ result/ # 统一响应封装 │ ├─ exception/ # 全局异常 │ └─ util/ └─ config前端目录有一个特意为之的设计按“角色端”分views目录而不是按“功能模块”分。src ├─ api/ # 按业务模块聚合接口请求 ├─ router/ # 动态路由注册 ├─ stores/ # Pinia状态用户信息、权限码、菜单 ├─ views/ │ ├─ platform/ # 招标代理端页面 │ ├─ supplier/ # 供应商端页面 │ └─ expert/ # 专家端页面 └─ components/原因很简单招投标系统里的同一套数据在不同角色眼里是完全不同的页面形态。供应商看到的“项目列表”是“我能报名的项目”代理人看到的“项目列表”是“我管理的项目”专家看到的则是“我的评审任务”。按角色隔离页面之后动态路由的权限控制可以做得非常直观登录后根据角色返回菜单前端用router.addRoute()动态挂载对应角色目录下的路由不需要在路由表里写一堆复杂的meta判断。3. 核心链路实现公告、报名、加密标书与在线开标3.1 数据库表设计的关键点截取几张核心表的简化结构重点看约束和字段命名-- 标段表状态机的核心对象 CREATE TABLE tender_section ( id BIGINT PRIMARY KEY AUTO_INCREMENT, project_id BIGINT NOT NULL, section_no VARCHAR(50) NOT NULL, section_name VARCHAR(200) NOT NULL, signup_start_time DATETIME, signup_end_time DATETIME, bid_submit_end_time DATETIME, status VARCHAR(30) NOT NULL, version INT DEFAULT 0, created_time DATETIME, updated_time DATETIME ); -- 供应商投标记录一标段一供应商只能有一条 CREATE TABLE bid_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, section_id BIGINT NOT NULL, supplier_id BIGINT NOT NULL, bid_status VARCHAR(30), file_name VARCHAR(255), file_hash VARCHAR(128), encrypted_key TEXT, -- 用开标公钥加密后的对称密钥 bid_price DECIMAL(18, 2), submit_time DATETIME, version INT DEFAULT 0, UNIQUE KEY uk_section_supplier (section_id, supplier_id) );两个容易被忽略的细节。第一时间字段要区分“报名截止时间”和“投标截止时间”接口参数命名必须自解释。我见过前端把signup_end_time和bid_submit_end_time混用导致供应商报名还没结束投标通道提前关闭的严重事故。这两个概念的语义在业务上差异很大建议在DTO和前端表单里都加上中文注释。第二bid_record表必须加唯一约束(section_id, supplier_id)这是防止重复提交的底线保障。应用层加锁、加判断都可能有疏漏唯一索引是最后一道安全网。3.2 公告发布采用“发布即快照”机制公告功能看起来简单实际藏着一个业务陷阱公告发布后如果有人编辑了正文已经报名或已提交标书的供应商看到的公告内容可能就变了这会引发质疑。所以我没有让公告页面直接展示数据库里的当前记录而是做了“发布即快照”。实现方式公告表里加一个content_snapshot字段点击“发布”按钮时把当前编辑的HTML内容、报名时间、投标截止时间等关键信息统一拷贝进快照字段。后续编辑草稿不影响已发布版本供应商端永远读快照。这个机制成本极低但能规避掉大部分宣传层面的纠纷。另外公告正文是富文本HTML入库前必须做XSS过滤。我用jsoup做白名单清洗只保留p、table、img、a、ul、li等安全标签去掉script、iframe和所有事件属性。这一步是安全底线不建议省。3.3 投标文件加密上传大文件混合加密方案标书加密上传是整个系统技术含量最高的部分也是最容易出问题的环节。直接讲我采用的混合加密方案供应商前端用随机生成的对称密钥SM4或AES-256加密整个投标文件再用服务端下发的RSA公钥加密这个对称密钥前端把加密后的文件本体和加密后的对称密钥一起上传服务器只保存密文和encrypted_key字段不落盘明文标书开标时由授权管理员触发解密服务端用RSA私钥解出对称密钥再用对称密钥流式解密文件。为什么不用RSA直接加密大文件因为RSA非对称加密对数据长度有限制而且速度很慢几十MB的文件直接加密会产生大量分段和性能问题。混合加密是行业标准做法性能和安全性兼顾。代码逻辑大致是// 开标解密先解出对称密钥 byte[] aesKey rsaDecrypt(bidRecord.getEncryptedKey(), privateKey); // 再用对称密钥流式解密文件 Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(aesKey, AES), new GCMParameterSpec(128, ivBytes)); try (CipherInputStream cis new CipherInputStream( new BufferedInputStream(new FileInputStream(cipherFile)), cipher)) { // 输出到受保护的临时评标目录 }对称加密优先选用AES/GCM/NoPadding因为它自带完整性校验密文被篡改会导致解密异常能直接暴露“文件被改动过”的情况。如果客户要求国密合规把算法换成SM4-GCM即可方案结构不变。还需要注意一个点上传完成后服务端要计算密文的SHA-256和前端提交的file_hash比对。如果哈希不一致要么是网络损坏要么是客户端文件被本地篡改系统要明确拒绝并提醒重新上传。这一行代码能在后面省掉无数扯皮。3.4 在线开标实时状态与WebSocket推送开标场景的核心诉求是“实时感”。开标时间一到管理员点击开始解密系统逐个解密标书供应商端要能看到解密进度、投标报价、供应商名称等开标一览信息。实时方案我选了WebSocket而不是纯前端轮询。原因很简单开标过程有个状态列表需要持续同步等待解密中、解密成功、解密失败、报价展示、结果确认。轮询也能做但3秒一次的请求在开标高峰时段会产生大量无意义的接口调用WebSocket只在状态变化时推送体验和开销都更好。服务端用一个内存Channel记录当前开标标段的订阅者解密流程每处理完一家供应商就推送一次消息{ type: DECRYPT_PROGRESS, sectionId: 1001, bidderId: 205, status: SUCCESS, bidPrice: 3280000.00 }前端拿到消息后局部更新开标一览表不需要全量刷新页面。如果后续要支持直播画面WebSocket通道依然可以承担“状态同步”职责视频画面单独用HLS流播放两者互不干扰。3.5 专家抽取与评分表专家抽取模块有一个容易被看轻的点它其实不是纯随机而是带条件的随机。需要从专家库里筛选出专业类别匹配、属地匹配、待回避单位不冲突、不在黑名单的专家然后在候选集里随机抽取。我用MySQL的ORDER BY RAND()加条件查询实现专家库数据量一般几千条性能完全够用。但抽取结果必须由管理员二次确认后才生效确认后锁定名单并发送短信通知这一步不能自动完成得留有人工审核环节。评标打分的表结构不能只存总分必须存每个专家对每个投标人每个评分维度的明细CREATE TABLE evaluation_score ( id BIGINT PRIMARY KEY AUTO_INCREMENT, evaluation_id BIGINT NOT NULL, expert_id BIGINT NOT NULL, bid_record_id BIGINT NOT NULL, criterion_id BIGINT NOT NULL, score DECIMAL(5, 2) NOT NULL, comment VARCHAR(500), create_time DATETIME );明细存储的价值在于一旦对评审结果有异议可以精确回溯到“哪位专家在哪个评分维度上给了多少分、写了什么意见”。总分由系统实时汇总计算不允许专家直接填一个总分这样最大程度上避免计算误差和人为干预。汇总时注意权重配置比如技术分权重60%、商务分40%权重本身也要存入配置表方便不同项目差异化设定。4. 权限、并发与审计这类项目最容易翻车的三件事4.1 权限模型前端只是方便后端必须兜底电子招投标系统里至少有三类完全不同的用户权限模型必须设计得严谨。我采用经典的RBAC模型用户-角色-权限三级权限精确到接口和按钮。后端在每个需要权限的接口上加自定义注解RequirePermission(tender:project:publish)通过AOP拦截器统一校验。拦截器从JWT里解析出用户角色集合再查一次权限码缓存判断是否放行。前端权限则是两件事一是动态路由登录后根据角色返回可访问菜单列表前端用router.addRoute()动态挂载二是按钮级权限用自定义指令v-permissiontender:project:delete控制删除按钮是否渲染。但这里必须强调一个原则前端权限只是界面交互上的友好处理真正的安全边界完全在后端。很多项目在前端隐藏了删除按钮却忘了后端接口只校验了登录状态、没有校验角色攻击者直接伪造一个HTTP请求就能把数据删掉。所以后端的RequirePermission校验必须覆盖全部敏感接口一个都不能漏。4.2 投标截止时间并发场景下的边界一致性投标截止时间是全系统最重要的时间边界。供应商的标书能不能提交直接以这个时间为准差一秒钟都可能引发投诉。我遇到的实际案例截止时间前后有部分供应商的上传请求恰好同时到达Nginx日志显示服务器响应时间已经超过截止时间但请求进入业务逻辑时代码里用的时间是从前端请求参数带过来的导致系统接受了超时标书。这是一个典型的“单机判断看似正确分布式场景下却出错”的问题。解决方案分三层第一层所有截止判断一律用后端服务器当前时间禁止信任前端传上来的时间戳第二层在更新投标记录之前对tender_section表的对应行执行SELECT ... FOR UPDATE行锁锁住标段后重新读取截止时间再做超时比较第三层bid_record表保持唯一约束防止同一供应商在同一标段重复插入。用行锁加二次校验把“检查截止时间”和“写入投标记录”变成一个原子操作这才算真正兜住了边界。4.3 操作留痕审计日志不能只打到log文件招投标系统的日志有两个用途开发排错和业务审计。这两个用途对日志格式的要求完全不同。普通logback日志难以按用户和业务维度查询所以业务审计日志必须单独建表。operation_log表记录字段说明operator_id操作人IDoperator_name操作人姓名operation_type操作类型编码如BID_SUBMIT、DECRYPT_OPENbusiness_key业务主键如标段IDrequest_param_md5请求参数摘要用于核验内容ip客户端IPuser_agent浏览器或客户端标识result_code成功或失败码cost_ms耗时毫秒数create_time操作时间重要业务操作通过自定义注解OperationLog加AOP自动落表不用每个Service方法手动写一遍日志代码。对开标、评标打分、标书解密这类关键动作我还额外维护了一张审计摘要表把上一条摘要哈希、当前操作关键字段拼接后再做SHA-256形成一条哈希摘要链。这样做的好处是如果事后有人直接改数据库里的评标分数摘要链校验会失败审计人员能第一时间发现记录被动过手脚。实现成本不高但面对招标代理和监管方时这套机制的说服力远超“我们记了日志”这句话。5. 实战排错记录我在这个项目里真实踩到的坑5.1 上传25MB标书直接报错三层配置都要查第一个坑是测试环境上传标书超过10MB就报504。排查过程印象深刻。一开始我怀疑是后端代码问题结果看后端日志完全没有请求进来。最后查到Nginx错误日志里写着client intended to send too large body这才意识到Nginx默认的client_max_body_size是1MB。上传请求被前置服务器直接拦截根本没到Spring Boot。解决时又踩了第二个小坑只调了Nginx还不够Spring Boot的spring.servlet.multipart.max-file-size默认只有1MB需要同时调大。我的最终配置spring: servlet: multipart: max-file-size: 50MB max-request-size: 70MBNginx配置client_max_body_size 50m;但如果只是调大这几个参数几十MB的文件上传时前端Axios默认不设超时时间大文件传输慢的话又会出现前端连接中断、服务端还在处理的尴尬情况。所以后来我干脆把上传改成了分片上传前端把文件切成5MB一片逐片上传全部传完后通知后端合并合并任务异步执行并返回taskId前端轮询合并进度显示进度条。这个改动虽然比单文件上传多写了一些代码但对大文件场景的稳定性和用户体验提升非常明显值得投入。5.2 Vue打包进SpringBoot后刷新页面404这个项目交付时要求打成单Jar包运行所以我把前端Vue项目npm run build后的dist目录放进了src/main/resources/static。从首页进入一切正常但一旦在浏览器直接刷新/supplier/bid/list这个子路由Spring Boot就返回404。原因是Vue Router用了HTML5的history模式前端路径是虚拟的后端不知道/supplier/bid/list这个路径应该映射到index.html。解决方法是注册一个通配转发Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController(/{path:[^\\.]*}) .forwardTo(/index.html); }注意这个配置有个前提后端接口路径不能和前端路由路径混在同一个根路径下。我的做法是所有API统一加/api前缀这样通配转发只处理无后缀的非API请求接口不会被误转。如果生产环境用的是Nginx部署前端更推荐直接配location / { try_files $uri $uri/ /index.html; }这样前端路由和API完全分离Spring Boot只负责提供/api接口省心很多。5.3 开标“解密失败”大面积报错不是密钥问题是文件名问题第一次真实开标演练时12家标书有9家解密失败。当时所有人都以为是密钥算法有问题排查到最后发现根本不是加解密的事而是文件落盘机制有隐患。最初我把上传文件保存为“供应商名称原始文件名.bin”比如北京某某建设有限公司-投标文件.bin。在Windows开发机上一切正常但客户的生产服务器是Linux。这些中文文件名在Windows上传时按GBK编码落盘到Linux后显示为乱码解密读取时用UTF-8去解析路径解析失败直接报文件不存在。另外供应商上传的原文件名里还带了空格和特殊字符解密时拼路径稍有不慎就出错。这次排错教我一个非常朴素的道理任何上传文件落盘命名一律用UUID.randomUUID().toString().replace(-, ) .bin原始文件名只存数据库解密时通过数据库查出来再展示给用户。存储层永远不要用业务含义作为文件名。另外一个更深的产品教训是解密失败的类型不能只显示“解密失败”四个字至少要区分“密钥错误”“文件损坏”“文件缺失”“文件路径不存在”四类。第一次展示开标时操作员看到一堆统一的“解密失败”只能打电话找开发查日志完全没有自助排查能力。后来我把异常分类细化解密失败时把具体原因返回给前端操作员能立刻知道是让供应商重新上传还是联系运维检查存储效率提升非常明显。5.4 定时任务把状态改乱了并发重复执行导致重复通知为了自动把投标截止时间已到的标段从“投标中”改成“投标截止”我写了一个Scheduled定时任务每分钟扫描一次数据库。本地单实例跑一切正常后来因为负载需要部署成两个服务实例问题立刻暴露两个实例在同一时刻执行扫描任务同一标段被处理了两次开标通知短信也重复发了两遍。排查链路如下先看短信平台收到的发送记录发现同一供应商在同一秒收到两条内容完全相同的通知再看数据库操作日志发现状态更新记录出现了两条。但看任务代码明明是先查状态、再改状态理论上第二遍查到的状态已经不是“投标中”怎么还会执行呢原因就在于两个实例同时查到了“投标中”的数据同时在内存里执行判断逻辑之后同时执行update。为了解决这个问题我在任务表上加了一把数据库锁// 任务开始前获取锁 int lockResult jdbcTemplate.update( INSERT INTO job_execution_lock(job_name, lock_owner, lock_time) VALUES(?, ?, NOW()) ON DUPLICATE KEY UPDATE lock_time lock_time, jobName, instanceName);当某个实例插入成功且受影响行数为1时说明它拿到了锁另一个实例执行同样的insert会走到ON DUPLICATE KEY UPDATE受影响行数为0说明已经有其他实例在处理直接跳过。任务执行完删除锁记录。这次排错让我对状态机的认识加深了一层手动操作和定时任务可能同时触发同一状态变更状态机里不但要校验旧状态到新状态是否合法还要结合操作来源人工/系统做幂等处理。比如短信通知模块在发送前先检查“该标段、该事件类型、该接收人”是否已经发过通知从应用层兜底彻底杜绝重复发送。6. 部署上线与后续演进的方向6.1 部署方案从裸Jar到docker-compose项目交付阶段我提供了两套部署方案客户可以根据自己的运维条件选择。第一套是传统方案适合没有容器化经验的机房环境前端用Nginx托管静态文件后端打成一个可执行Jar包用systemd做守护进程管理。配置/etc/systemd/system/tender-backend.service设置ExecStartjava -jar tender-backend.jar --spring.profiles.activeprod开机自启和崩溃自动重启都交给systemd处理。第二套是docker-compose方案适合客户用宝塔面板或自建服务器做容器化部署services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: changeit MYSQL_DATABASE: tender volumes: - ./mysql-data:/var/lib/mysql redis: image: redis:6 backend: build: ./backend depends_on: - mysql - redis environment: SPRING_PROFILES_ACTIVE: prod frontend: image: nginx:1.24 volumes: - ./frontend/dist:/usr/share/nginx/html - ./nginx.conf:/etc/nginx/conf.d/default.conf ports: - 80:80 - 8080:8080要注意的部署细节生产环境的MySQL必须关闭外网映射端口只允许容器内网访问Redis必须设置密码上传文件目录和日志目录用Volume挂载出来避免容器重启丢数据。这些安全管理项在招投标类系统里不是加分项而是标配。6.2 值得投入的扩展方向电子签章、开标直播、存证审计这个系统基础功能做完以后有几个扩展点我认为价值极高如果客户预算允许可以优先考虑。第一是电子签章。目前标书加密方案能保证传输和存储环节的机密性但“这份文件是谁签发的”还需要借助CA数字证书和电子签章SDK来实现。在标书内嵌入盖章后的PDF版本开标后展示的就是带法律效力的签章文件。这部分集成工作量不小尤其服务端签章往往要依赖第三方库而且签名验签环节的调试周期长建议单独规划迭代周期。第二是开标直播。如果客户要求“远程异地开标”除了WebSocket推送开标状态还需要推流直播画面。前端可以用vue-video-player配合hls.js播放HLS流后端对接开源流媒体服务。这块的技术栈刚好和当前Vue生态契合扩展开来不算难。第三是存证审计升级。我在前面提到的哈希摘要链可以进一步延伸到外部存证服务把开标记录、评标明细、关键公告摘要的哈希同步到独立的存证平台。有了第三方存证背书整个系统的可信度会上一个台阶招标代理拿这个去参加监管审查时也更有说服力。最后说一点个人体会。做完这个SpringBootVue电子招投标系统我最大的感触是写代码的时间其实只占整个项目的一部分前期和业务方对齐状态机、确认角色边界、定义截止时间语义这些“画表格”的工作才是决定项目成败的关键。技术方案本身没有太多玄学SpringBoot稳定可靠、Vue灵活高效它们组合在一起完全撑得起这类B端业务系统。真正的复杂度和价值都藏在业务规则和边界条件的细节里。如果你正要开始类似项目建议不要急着开IDE先把角色、状态机、时间边界这三张表画清楚后面能少走很多弯路。