校园商铺平台项目走到第14个迭代,店铺信息编辑Controller这一步看着只是一天工作量的事,做起来却意外地缠人。前面十来个迭代把店铺入驻、商品管理、订单流程都串通了,商家端的功能闭环基本成形,到了"改店铺信息"这个需求,表面上无非是暴露一个更新接口,真正下手才发现,编辑场景和新增场景根本不是一回事——字段能不能改、改了怎么校验、更新失败要不要回滚、并发下怎么防覆盖,每一个都是坑。这篇文章我就把店铺信息编辑Controller从接口设计、参数校验、Service配合到踩坑记录完整拆开,把我实际写过的代码、试过的方案和最后留下的取舍一并放出来,给正在做同类校园平台、电商商家端项目的朋友做一个可参考的底稿。
1. 项目整体回溯:店铺信息编辑在这个迭代里到底解决什么问题
1.1 为什么要为"编辑店铺信息"单独做一个Controller
第13个迭代结束的时候,我们的校园商铺平台已经跑通了商家入驻申请、后台审核、商品发布这条链路。但商家入驻时填的店铺信息,本质上是一次性的初始数据,真正经营起来之后,商家必然要改店名、换头像、调整营业时间、修改联系电话。当时线上跑了几周,运营那边反馈最多的就是"商家想改店铺介绍,结果只能找后台人工处理",这显然不可持续。
把编辑功能做进Controller层,并不是简单地照抄一个update接口。店铺信息有一个很特殊的性质:它是平台的核心数据之一,所有商品、订单、结算都挂在店铺ID上。如果店铺信息编辑接口允许乱改关键字段,比如把店铺状态改了、把归属的店主ID换了,整个平台的数据根基就动摇了。所以这个Controller的核心职责,除了"接收参数并更新数据库",更重要的是划定边界——哪些字段开放给商家改,哪些字段必须锁死,哪些字段改了之后还要联动刷新别的地方。
这也是我要为编辑单独写一个Controller,而不是直接复用店铺入驻接口的原因。入驻接口走的是"创建"逻辑,状态机是待审核到审核通过,字段约束和编辑场景完全不同。把两套逻辑硬塞到一个方法里,后期改一个需求就会牵动另一个,维护成本翻倍。
1.2 技术栈与分层设计上的整体思路
项目整体用的是Spring Boot 2.7 + MyBatis-Plus 3.5 + MySQL 8.0,前端是Vue的管理后台。这个组合在校园项目里非常常见,没有太重的中间件,除了MySQL之外只引入了一个Redis做缓存和登录态存储。
店铺信息编辑Controller的代码结构沿用了项目一贯的三层架构:Controller只做参数接收和响应返回,Service层负责业务规则和事务控制,Mapper层由MyBatis-Plus提供基础的CRUD能力。没有在这层引入额外的设计模式,但如果把一个Controller写得过于"瘦",把所有判断都堆在Service,又会导致Service方法几百行不便于阅读。我最终的取舍是:校验规则尽量用注解写在DTO上,业务状态判断放在Service里,Controller保持在一个"转发器"的职责范围。
这种分层的价值在于,后期如果要把店铺编辑功能开放给不同角色(商家本人、平台运营、超级管理员),只需要在Service层增加角色判断或增加独立的校验方法,Controller接口签名可以保持稳定,前端也不至于跟着频繁改联调。
2. 接口设计与参数校验:先把"改什么"和"怎么改"定清楚
2.1 RESTful风格选择:PUT还是POST
关于店铺信息更新接口用PUT还是POST,我专门花了一点时间对比。现在很多团队习惯把所有写操作都用POST,原因无非是"POST兼容性好,PUT容易被某些老旧客户端搞出问题"。但我个人在这个项目里选的是PUT,更准确地说,是PUT /api/shop/{id}这样的语义化路径。
逻辑很简单:PUT在HTTP语义中表示"对资源的整体替换",我们用编辑接口提交的是店铺信息的完整可编辑字段集合,符合全量更新的定义。同时PUT天然是幂等的,商家前端触发重复提交时,传同样的参数执行多次,最终数据库里的数据是一致的,不会因为网络重试而制造出脏数据。这一点在移动端弱网环境下尤其重要,商家手滑点了两次保存,后台不会重复累计修改记录。
配置上也很直接:
@PutMapping("/{id}") public ResponseResult<Void> updateShopInfo(@PathVariable("id") Long id, @RequestBody @Validated ShopUpdateDTO dto) { shopInfoService.updateShopInfo(id, dto); return ResponseResult.success(); }注意:使用PUT并不意味着可以放松对参数完整性的要求。恰恰因为PUT是"整体替换",接口接收的DTO里一旦出现空字段,就有可能在更新时把库里的原值覆盖成空。这个坑我在后面的第三节和第四节里会展开讲。
2.2 店铺编辑DTO的设计与字段分组
店铺信息编辑不能直接把数据库实体ShopInfo暴露给前端接收,这是我踩过几轮之后才固化的规矩。数据库实体里有status、ownerId、version、createTime这些字段,如果把它们一起绑定进来,恶意请求甚至可以把自己店铺的状态改成已关闭,或者把别人的店铺ID塞进来。
我定义的编辑DTO大致是这样:
@Data public class ShopUpdateDTO { @NotBlank(message = "店铺名称不能为空") @Size(max = 30, message = "店铺名称最长30个字符") private String shopName; @Size(max = 200, message = "店铺简介最长200个字符") private String shopDesc; @Pattern(regexp = "^1[3-9]\\d{9}$", message = "联系电话格式不正确") private String contactPhone; @Size(max = 255, message = "店铺头像URL过长") private String shopLogo; @Size(max = 50, message = "营业时间描述最长50个字符") private String businessHours; private Long categoryId; }可编辑字段只有六个:店铺名称、店铺简介、联系电话、店铺头像、营业时间、店铺分类。其中店铺名称和联系电话是必填项,其余允许为空。这六个字段都是商家在经营过程中高频需要调整的,同时也是安全可控的,不会影响到店铺的资质审核状态和归属关系。
不可编辑的字段也没有遗漏:id(店铺ID)从路径参数取,不允许从请求体传;ownerId(店主ID)在Service里从登录态获取,不做任何绑定;status(店铺状态)由审核流程单独管理;version(乐观锁版本号)由Service层读取当前值,前端不感知。
2.3 参数校验的分层:DTO注解 + Service业务校验
我的经验是,校验不能只做一层。DTO上的注解解决的是"格式对不对"的问题,Service里的判断解决的是"业务上允不允许"的问题,两层互相不能替代。
DTO注解这一层用的是JSR-303的@Validated,配合Spring Boot自带的校验器,Controller进入方法体之前就能拦截掉大多数低级错误,比如店铺名没填、电话格式不对。前端拿到异常消息后可以直接精准提示,不需要再往后端数据库层面追究。
Service层的业务校验解决的是更"重"的问题。比如当前登录用户是否真的是这个店铺的店主、店铺是否存在、店铺是否处于允许编辑的状态(如果店铺因为违规被冻结,商家就不应该还能随意修改信息)。这些判断依赖数据库查询和登录态,放在DTO注解里做不了,必须下沉到Service。
一个典型例子是店铺状态校验:
if (shop.getStatus() != null && shop.getStatus() != 1) { throw new BusinessException("当前店铺状态不允许编辑"); }状态值是1表示正常营业,其他状态(待审核、已冻结、已关闭)都不允许走编辑通道。操作被拦截在入口处,而不是等到数据库更新后才发现状态不对,这能减少很多脏数据链路的可能性。
3. 核心代码落地:店铺信息编辑Controller的完整实现
3.1 Service层的更新逻辑与乐观锁处理
很多同学写Controller会跳过Service直接在Controller里调用Mapper,短平快,但一旦需要加业务判断就麻爪。我这里还是把主要逻辑放在了Service,Controller只做参数编排。Service的更新方法是这样实现的:
@Transactional(rollbackFor = Exception.class) public void updateShopInfo(Long shopId, ShopUpdateDTO dto) { // 1. 校验店铺存在 ShopInfo shop = this.getById(shopId); if (shop == null) { throw new BusinessException("店铺不存在"); } // 2. 校验归属:只允许店主本人编辑 Long currentUserId = LoginUserHolder.getUserId(); if (!shop.getOwnerId().equals(currentUserId)) { throw new BusinessException("无权操作该店铺"); } // 3. 校验店铺状态 if (shop.getStatus() == null || shop.getStatus() != 1) { throw new BusinessException("当前店铺状态不允许编辑"); } // 4. 组装更新实体 ShopInfo updateEntity = new ShopInfo(); BeanUtils.copyProperties(dto, updateEntity); updateEntity.setId(shopId); updateEntity.setVersion(shop.getVersion()); // 乐观锁版本 updateEntity.setUpdateTime(LocalDateTime.now()); // 5. 执行更新,乐观锁由MyBatis-Plus自动拼接 WHERE version = ? boolean updated = this.updateById(updateEntity); if (!updated) { throw new BusinessException("更新失败,店铺数据已被其他人修改,请刷新后重试"); } // 6. 缓存处理 cacheService.evictShopInfo(shopId); }这里有一个细节值得展开:this.updateById(updateEntity)配合@Version注解,MyBatis-Plus会在生成SQL时自动带上WHERE id = ? AND version = ?,执行成功后受影响行数为1,更新后version字段会自增。如果两个请求同时读到version=3,先后提交,后提交的那个因为version已经变成4,SQL匹配不到数据,返回受影响行数为0,这时updated就是false,我们就可以明确告诉用户"数据被改了,请刷新再试"。
3.2 BeanUtils拷贝与字段更新陷阱
上面代码里用了BeanUtils.copyProperties(dto, updateEntity),这一步看起来很省事,实际有暗坑。DTO里的字段和实体字段名一一对应,拷贝确实快,但有个前提:如果某个字段前端没传,DTO里就是null,拷贝到实体后也是null。在MyBatis-Plus的updateById默认策略里,null字段不参与更新,这是安全的。
但同样这个copy动作,如果哪天DTO里新增了一个字段,恰好和实体里不能改的字段重名,比如不小心把status放进DTO,拷贝就会把前端传的status值带入实体,进而更新到数据库。所以我的做法是:DTO中干脆不定义那些敏感字段,从源头堵死。这不是什么高深技巧,就是一条工程经验——能靠字段隔离解决的问题,不要在Service里做大量if判断。
还有一点要特别注意,更新店铺名称时,如果新的店铺名和其他店铺重复,数据库层面会报唯一索引冲突。这里我没有选择预查询一遍"这个名字是否已被占用",而是捕获DuplicateKeyException再转成友好提示。原因很简单,并发场景下预查询无法杜绝竞争窗口,最终还是要靠数据库唯一索引兜底,不如直接让数据库报错,再用统一异常处理器翻译。
3.3 统一响应结构与全局异常处理
这个项目从第1个迭代开始就统一用ResponseResult<T>作为所有接口的返回结构,店铺编辑接口完全复用,没有为它单独定义返回类型。
@Data public class ResponseResult<T> { private Integer code; private String message; private T data; public static <T> ResponseResult<T> success() { ResponseResult<T> result = new ResponseResult<>(); result.setCode(200); result.setMessage("操作成功"); return result; } public static <T> ResponseResult<T> error(int code, String message) { ResponseResult<T> result = new ResponseResult<>(); result.setCode(code); result.setMessage(message); return result; } }配套的全局异常处理器是所有Controller共用的,店铺编辑接口的校验异常和业务异常都会走到这里:
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseResult<Void> handleValidException(MethodArgumentNotValidException e) { String msg = e.getBindingResult().getFieldErrors().stream() .map(FieldError::getDefaultMessage) .collect(Collectors.joining(";")); return ResponseResult.error(400, msg); } @ExceptionHandler(BusinessException.class) public ResponseResult<Void> handleBusinessException(BusinessException e) { return ResponseResult.error(500, e.getMessage()); } }前端拿到非200的code之后,把message直接弹给商家,不用自己做错误文案映射,省了一堆麻烦。
4. 实战踩坑记录与问题排查实录
4.1 前端死活拿不到具体的校验提示
第一个坑出现在联调阶段。前端用axios提交表单,后端DTO校验不通过时,前端拿到的响应始终是浏览器默认的400错误,而不是JSON格式的业务提示。原因是MethodArgumentNotValidException默认返回结构是Spring Boot的DefaultErrorAttributes,里面虽然也带message,但消息内容是"Validation failed for object...",前端根本没法直接显示给用户。
我当时的处理是在全局异常处理器里新增了对MethodArgumentNotValidException的捕获,从BindingResult里取出每条FieldError的defaultMessage,拼成一句完整的中文提示。这样一来,前端拿到的响应体里就有了"店铺名称不能为空;联系电话格式不正确"这样的直接信息,不用再做任何解析。
这个坑的核心问题在于,很多人以为加了@Validated注解就算校验完成了,但实际上只完成了一半——校验结果的格式化表达是另一半,不做的话接口就是个装了校验器却没有友好反馈的黑盒子。
4.2 空字符串把数据库原有值覆盖成空了
第二个坑是我自己在测试时发现的。前端表单里有几个非必填项,比如"店铺简介"和"营业时间",商家没填就提交,前端传的是空字符串""而不是null。MyBatis-Plus默认字段策略里,""不是null,它会被当作有效值参与更新,结果就是把数据库里原本写得好好的一段店铺简介直接覆盖成了空字符串。
解决思路有两种。第一种是在DTO上把非必填字段都用@Size校验一遍,但@Size(min=0)挡不住空串覆盖的逻辑。第二种是给DTO的空串做一个统一处理,在Controller入口把空字符串转换为null,这样MyBatis-Plus的默认更新策略(null不更新)就能保住原值。
我实际是在Controller入口加了一个轻量级的空值处理:
if (dto.getShopDesc() != null && dto.getShopDesc().trim().isEmpty()) { dto.setShopDesc(null); } if (dto.getBusinessHours() != null && dto.getBusinessHours().trim().isEmpty()) { dto.setBusinessHours(null); }提示:这个处理只能放在Controller入口,不能在DTO构造方法里做,Spring的反序列化不走构造方法逻辑,容易失效。如果项目里有很多DTO都要处理空串,可以写一个通用的反序列化器,在JSON解析阶段统一把空字符串转为null,一劳永逸。
4.3 并发编辑导致旧数据覆盖新数据
第三个坑来自并发。商家在电脑上打开编辑页面,同时手机上也打开了同一个页面,两边一起改了店铺介绍并点击保存。如果没有乐观锁,后提交的一方会用自己页面上的旧数据把先提交的新数据整个覆盖掉,这个在多个终端的商家场景里很容易出现。
我们的ShopInfo实体已经加了@Version注解,整条链路天然具备乐观锁能力。出现乐观锁冲突时,updateById返回false,Service抛出"数据已被其他人修改,请刷新后重试"的异常,不会静默覆盖。这里我要特别提醒一点:@Version注解的字段类型必须是Long或者Integer,不能是String,否则MyBatis-Plus的乐观锁插件不生效。
我见过一些项目在实体上加了@Version,但因为没有注册MybatisPlusInterceptor的乐观锁插件,导致这个注解形同虚设。配置其实就两行:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; }没有这个Interceptor,version字段不会自动参与更新条件,也就防不住并发覆盖。
4.4 店铺简介里的恶意脚本内容
店铺简介这种自由文本字段,天然就有XSS攻击面。商家如果把简介写成<script>alert('xss')</script>,存到数据库里,管理后台再去渲染这个字段,脚本就有机会执行。在校园平台这种多商家场景,一个商家通过简介注入脚本,影响的是运营后台甚至其他用户。
我的处理是在Service更新前,对shopDesc做一次HTML标签过滤,用项目里已有的工具类把尖括号转义存储,展示时再做反转。后来因为前端用的是Vue,Vue默认插值会转义HTML,风险等级降低,但转义存储这件事我一直保留着——因为后台接口不止前端一个调用方,日志系统、导出报表都可能读取这个字段,源头干净才最稳妥。
5. 可扩展性思考与后续演进方向
5.1 编辑接口的幂等性再讨论
上面说过PUT本身带幂等性,但完整的幂等还依赖前端做防重复提交。我在前端做了一个非常简单有效的处理:点击保存按钮后立即禁用按钮,等到接口返回或者超时后再恢复。这只是体验层的兜底,真正的幂等保障还是在数据库和乐观锁。
如果说得更深入一点,如果后续要把"店铺信息编辑"做成一个支持"撤销修改"的功能,就需要给每次编辑生成一个审计记录,并且保存编辑前后的字段快照。这时候接口的返回就不再是ResponseResult<Void>,而是要返回一条editId,前端拿着这个editId可以调用撤销接口。这些改动不会影响现有Controller的结构,只需在Service里增加审计写入。
5.2 权限扩展:从"店主本人"到"多角色协作"
现在的实现逻辑很单纯,只有店主本人能改自己店铺的信息。但校园商铺平台发展到后面,一个店铺可能会有一个主理人和一个店员,甚至运营人员也会被授权帮忙修改店铺资料。届时Controller不需要变化,Service里的归属校验需要抽出一个独立的权限判断器,支持按角色和按店铺维度做授权。
这个演进思路我在写当前位置校验时就留了余地:所有权限判断都集中在一个LoginUserHolder和ShopPermissionChecker里,没有散落在各个Service方法。后面要扩权,改一个类就好。
5.3 编辑字段变更时的下游联动
还要提一个我在当前迭代里已经埋下的伏笔:店铺名称和店铺分类一旦变更,商品列表页、店铺页展示的冗余字段需要联动更新。当前商品表里冗余存了shopName字段,如果只改店铺表的不同步改商品表,商详页显示的店铺名就是旧的。
我目前的方案是编辑成功后发送一个Spring事件ShopInfoChangedEvent,商品搜索服务监听这个事件后异步更新冗余字段。这个联动逻辑没有写在Controller里,而是放在Service层的事件发布,Controller仍然保持单一职责。后续如果引入消息队列,也可以把这个事件发布替换成MQ消息,接口层面完全不受影响。
最后说一点个人体会
十几个迭代走下来,我越来越觉得,一个看似简单的编辑Controller,真正写得好不好,要看它对"边界"的理解深不深。参数边界、权限边界、状态边界、并发边界,每一项都要在代码里给出明确的答案。这一轮把店铺信息编辑接口做完之后,我最大的改变就是:以后再写任何更新类接口,一定会先列一遍"哪些字段不允许更新",而不是急着打开编辑器写方法体。
还有一个可以分享的小技巧:把店铺编辑接口对着表结构重新读一遍,凡是看到ownerId、status、createTime这些字段出现在DTO里,直接删除。养成这个习惯之后,安全性和稳定性都会好很多,用说的。"
我已经完成了草稿。