当前位置: 首页 > news >正文

用户只需要知道「怎么办」,不需要知道「为什么炸了」

大家好,我是晓凡。

写在前面

一到月初或者月末(某些业务操作大规模爆发的时候),手机狂震,生产告警狂轰滥炸:xxx接口超时、用户中心 CPU 飙到 98%……

运维在群里疯狂 @ 你,你却只能回一句“我本地是好的”。

别问,问就是接口设计欠下的技术债。

下面,晓凡总结成 18 条可落地的接口设计“军规”。每条都配上“作死写法”与“保命写法”。


军规 1:路径必须永久不变

反面教材

@RestController
@RequestMapping("/getUserInfoByIdV2.3_beta")
public class UserController { ... }

产品说“V2.3_beta”只是临时版本,结果半年后,死活不敢下线。

正面写法

@RestController
@RequestMapping("/users")
public class UserController {@GetMapping("/{uid}")public UserDTO get(@PathVariable Long uid) { ... }
}

版本号放到 Header:Accept: application/vnd.myapp.v2+json
路由一旦上线,就是“墓碑”,永远不许动,哪怕老板喊重构。


军规 2:命名只准用名词,禁止动词

反面教材

@PostMapping("/createOrder")
@PostMapping("/addOrder")
@PostMapping("/insertOrder")

同一个业务三个入口,新人入职三天就开始迷路。

正面写法

@PostMapping("/orders")
public OrderDTO create(@RequestBody CreateOrderCommand cmd) { ... }

HTTP 动词已经表达“创建”语义,别再动词叠 buff。


军规 3:统一用复数

反面教材

@GetMapping("/order/{id}")
@GetMapping("/orders")

单复数混用,前端拼接 URL 得写 if/else,特别容易出错。

正面写法

@GetMapping("/orders/{id}")
@GetMapping("/orders")

集合与成员保持一致,前端直接模板字符串 ${host}/orders/${id},代码干净又整洁。


军规 4:分页参数必须“三件套”

反面教材

@GetMapping("/orders")
public List<Order> list(@RequestParam int offset,@RequestParam int limit) { ... }

参数名随心所欲,前端封装 不知道骂了你多少次。

正面写法

@GetMapping("/orders")
public PageResult<OrderDTO> list(@RequestParam(defaultValue = "1") @Min(1) int page,@RequestParam(defaultValue = "20") @Min(1) @Max(100) int perPage) {long total = orderMapper.count();List<OrderDTO> data = orderMapper.selectPage((page - 1) * perPage, perPage);return PageResult.<OrderDTO>builder().data(data).totalCount(total).hasNext(page * perPage < total).build();
}

返回统一包装:

@Data
@Builder
public class PageResult<T> {private List<T> data;private long totalCount;private boolean hasNext;
}

军规 5:字段命名一律小写加下划线

反面教材

{"userName":"Jack","userAge":18}

前端 axios 自动把下划线转小驼峰,结果文档对不上,联调 2 小时。

正面写法

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class UserDTO {private String userName;private Integer userAge;
}

返回即:

{"user_name":"Jack","user_age":18}

前后端一人一把尺子,永远对得上。


军规 6:枚举值禁止用魔法数字

反面教材

if (order.getStatus() == 3) { ... }   // 3 代表啥?鬼知道

DB 改个值,线上直接 500。

正面写法

public enum OrderStatus {CREATED(10),PAID(20),SHIPPED(30),DONE(40);private final int code;OrderStatus(int code) { this.code = code; }public int getCode() { return code; }
}

实体与数据库均存 code:

@Converter(autoApply = true)
public class OrderStatusConverter implements AttributeConverter<OrderStatus, Integer> {public Integer convertToDatabaseColumn(OrderStatus s) { return s.getCode(); }public OrderStatus convertToEntityAttribute(Integer c) {return Arrays.stream(OrderStatus.values()).filter(e -> e.getCode() == c).findFirst().orElseThrow(() -> new IllegalArgumentException("unknown code " + c));}
}

代码里只有枚举,没有魔法数字。


军规 7:接收参数必须 DTO,禁止 Map

反面教材

@PostMapping("/orders")
public OrderDTO create(@RequestBody Map<String,Object> map) {Integer skuId = (Integer) map.get("skuId");  // 强转爆炸
}

Map 一把梭,编译期 0 提示,运行时 ClassCastException 随机出现。

正面写法

@PostMapping("/orders")
public OrderDTO create(@RequestBody @Valid CreateOrderCommand cmd) { ... }@Data
public class CreateOrderCommand {@NotNullprivate Long skuId;@NotNull @Min(1)private Integer quantity;
}

校验失败自动 400,错误信息一目了然:

{"field":"quantity","message":"must be greater than or equal to 1"}

军规 8:统一返回包装,禁止裸奔

反面教材

@GetMapping("/orders/{id}")
public OrderDTO get(@PathVariable Long id) { ... }

成功返回对象,失败返回字符串,前端得写三行 if 判断类型。

正面写法

@RestControllerAdvice
public class CommonResponseAdvice implements ResponseBodyAdvice<Object> {public boolean supports(MethodParameter returnType, Class converterType) { return true; }public Object beforeBodyWrite(Object body, MethodParameter returnType,MediaType selectedContentType, Class selectedConverterType,ServerHttpRequest request, ServerHttpResponse response) {if (body instanceof CommonResult) return body;   // 避免二次包装return CommonResult.success(body);}
}public class CommonResult<T> {private int code;private String msg;private T data;public static <T> CommonResult<T> success(T data) {return new CommonResult<>(0, "ok", data);}
}

前端唯一判断 code === 0,其余按错误弹窗。


军规 9:错误码必须分段

反面教材

new RuntimeException("订单不存在");

日志里只有一行文字,定位靠天意。

正面写法

@Getter
@AllArgsConstructor
public enum ErrorEnum {ORDER_NOT_FOUND(20001, "订单不存在"),SKU_NOT_AVAILABLE(20002, "商品库存不足");private final int code;private final String message;
}

全局异常处理:

@RestControllerAdvice
public class GlobalExceptionHandler {@ExceptionHandler(BizException.class)public CommonResult<Void> handle(BizException ex) {return CommonResult.fail(ex.getErrorEnum().getCode(),ex.getErrorEnum().getMessage());}
}

前端按码弹窗,20001 跳转“订单列表”,20002 跳转“商品详情”。


军规 10:接口必须幂等

反面教材

@PostMapping("/orders")
public OrderDTO create(@RequestBody CreateOrderCommand cmd) {return orderService.create(cmd);   // 每次调用都插新订单
}

用户狂点按钮,瞬间 5 单,客服哭晕。

正面写法

@PostMapping("/orders")
public OrderDTO create(@RequestBody @Valid CreateOrderCommand cmd,HttpServletRequest request) {String idempotencyKey = request.getHeader("Idempotency-Key");if (idempotencyKey == null) throw new BizException(ErrorEnum MISSING_KEY);return idempotencyService.execute(idempotencyKey, () -> orderService.create(cmd));
}

Redis 缓存 24h 唯一 KEY,重复请求直接返回第一次结果,0 重复订单。


军规 11:日期格式只准 ISO8601

反面教材

{"createTime":"06/18/2025 09:05:12"}

万一有国外项目,同事一脸懵:这是 6 月还是 18 月?

正面写法

@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", timezone = "UTC")
private OffsetDateTime createTime;

返回即:

{"create_time":"2025-06-18T09:05:12+00:00"}

前端 new Date('2025-06-18T09:05:12+00:00') 直接解析,时区 0 歧义。


军规 12:Long 主键后端转 String

反面教材

{"orderId":9223372036854775807}

JS 最大安全整数 2^53-1,订单号精度丢失,用户 A 的订单跑到用户 B。

正面写法

@JsonSerialize(using = ToStringSerializer.class)
private Long orderId;

返回即:

{"order_id":"9223372036854775807"}

前端字符串传,精度不丢失。


军规 13:批量接口必须限制数量

反面教材

@PostMapping("/orders/batch")
public List<OrderDTO> batch(@RequestBody List<Long> ids) { ... }

对方一次丢 10w 个 id,线程池直接拉满。

正面写法

@PostMapping("/orders/batch")
public List<OrderDTO> batch(@RequestBody @Size(max = 100) List<Long> ids) { ... }

超过 100 直接 400,爱用不用。


军规 14:文件上传必须预签名

反面教材

@PostMapping("/upload")
public String upload(MultipartFile file) { ... }

1G 视频直接把带宽打爆,Tomcat OOM

正面写法

@GetMapping("/upload/token")
public UploadTokenDTO token(@RequestParam String suffix) {String key = "private/" + UUID.randomUUID() + suffix;String uploadUrl = ossClient.generatePresignedUrl(key,  ExpirationEnum.TEN_MINUTES);return new UploadTokenDTO(uploadUrl, key);
}

前端拿到直传 OSS,服务端只存 key,流量 0 占用。


军规15:禁止把「内部错误码」直接抛给前端

反面教材

catch (Exception e) {log.error("RPC失败", e);return CommonResult.fail(999, e.getMessage());   // 999 是什么?只有我自己懂
}

结果:
前端拿到 {code:999, msg:"Read timed out executing POST http://stock-service/lock"},直接把超时堆栈展示给用户,页面弹出「Read timed out…」——用户一脸懵,黑客倒开心,内网地址全暴露。

正面写法

1.对外错误码只保留「用户可理解」枚举,统一收敛:

@AllArgsConstructor
public enum FrontEndErrorEnum {STOCK_UNAVAILABLE(5100, "商品库存不足"),SYSTEM_BUSY(5101, "系统繁忙,请稍后重试"),UNKNOWN_ERROR(5999, "网络走神了,稍后再试");final int code;final String message;
}

2.全局异常层做「内外翻译」——任何底层异常都不准穿透:

@RestControllerAdvice
public class ErrorTranslator {@ExceptionHandler(Exception.class)public CommonResult<Void> handle(Exception ex) {log.error("Fetal error", ex);          // 详细堆栈只写日志if (ex instanceof FeignException) {    // 下游超时return CommonResult.fail(FrontEndErrorEnum.SYSTEM_BUSY);}return CommonResult.fail(FrontEndErrorEnum.UNKNOWN_ERROR);}
}

3.前端拿到的是:

{"code":5100,"msg":"商品库存不足"}

既安全又友好,还方便做国际化——以后想换提示语,只改枚举即可

用户只需要知道「怎么办」,不需要知道「为什么炸了」。把堆栈留在日志,把尊严留给产品。


军规 16:对外暴露 Swagger,对内必须加注解

反面教材

@RestController
public class OrderController {@PostMapping("/orders")public OrderDTO create(CreateOrderCommand cmd) { ... }
}

文档靠口口相传,字段一旦改名,测试小姐姐提刀来找。

正面写法

@Tag(name = "订单模块")
@RestController
public class OrderController {@Operation(summary = "创建订单")@ApiResponse(responseCode = "200", description = "成功")@PostMapping("/orders")public OrderDTO create(@RequestBody @Valid CreateOrderCommand cmd) { ... }
}

启动后 http://localhost:8080/swagger-ui.html 实时可见,改字段即报错,0 沟通成本。


军规 17:关键接口必须打印入参出参

反面教材

@PostMapping("/orders")
public OrderDTO create(@RequestBody CreateOrderCommand cmd) {return orderService.create(cmd);
}

线上出错,日志只有一行“NullPointerException”,想复现?随缘。

正面写法

@PostMapping("/orders")
public OrderDTO create(@RequestBody CreateOrderCommand cmd) {log.info("create order req: {}", cmd);OrderDTO dto = orderService.create(cmd);log.info("create order rsp: {}", dto);return dto;
}

配合 Logback 异步 + 脱敏:

<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender"><queueSize>2048</queueSize><appender-ref ref="FILE"/>
</appender>

性能损耗 < 5%,问题排查速度大大提升。


军规 18:发版前必须做向后兼容扫描

反面教材

// V1
public class UserDTO { private String name; }// V2 直接把 name 改成 username
public class UserDTO { private String username; }

旧应用直接解析失败,用户体验非常不好

正面写法

  1. 加新字段,不动旧字段:
public class UserDTO {private String name;        //  deprecatedprivate String username;    //  新字段
}
  1. 使用 @Deprecated 注解,Swagger 自动标灰。
  2. 配套单元测试:
@Test
void v1ClientShouldStillSeeNameField() throws Exception {mockMvc.perform(get("/users/1").header("Accept", "application/vnd.myapp.v1+json")).andExpect(jsonPath("$.name").exists()).andExpect(jsonPath("$.username").doesNotExist());
}
  1. 上线后观察 7 日,旧字段无调用再下线。

小结

接口设计不是炫技,而是写“半年后看了自己之前写的代码,还敢重构的代码勇气”。

这 18 条军规,一半来自我的踩坑,一半来自“别人踩过的坑”。
别嫌啰嗦,真正上线 0 告警的那天,你会来感谢我。

愿下次手机响起,只是外卖到了,不是 502。

我是晓凡,再小的帆也能远航

我们下期再见ヾ(•ω•`)o (●'◡'●)

http://www.wxhsa.cn/company.asp?id=7481

相关文章:

  • 2025数学院士增选背后的争议:海外光环与本土贡献的考量
  • 完整教程:建筑物裂缝、钢筋裸漏、建筑物墙面脱落图像数据集
  • 深入剖析布谷网剧短剧app系统软件源码之技术
  • 在AI技术快速实现功能的时代,挖掘电子书阅读器新需求成为关键突破点
  • PHP 如何利用 Opcache 来实现保护源码
  • 给RAG打分:小白也能懂的AI系统评测全攻略
  • P8114 [Cnoi2021] 六边形战士
  • 【GitHub每日速递 250918】开发者必藏!336k 星标项目告诉你:前端 / 后端 / AI 岗该怎么学才高效
  • css-4
  • 【操作系统】从实模式到保护模式,
  • Flutter CSV导入导出:大数据处理与用户体验优化
  • 读人形机器人15未来城市
  • 解锁智能检索新境界:CriticGPT 赋能检索模型洞察人类偏好
  • NET 中 Async/Await 的演进:从状态机到运行时优化的 Continuation
  • 最长公共子序列
  • 使用 Ansible 管理服务器集群
  • Codeforces Round 1051 (Div. 2)
  • 再不学就晚了!RDT LeRobot与RDKS100部署详解
  • 编译Unity4.3.1f1
  • 【R课堂-电机专栏】为什么提高电机的电压时,转速会随之上升?
  • 抽象 CF
  • 单元测试之Mockito使用
  • Jetson有Jtop,Linux有Htop,RDK也有Dtop!
  • 《原子习惯》-读书笔记4
  • Java学习第三天
  • Java学习第四天
  • java学习第一天
  • Java学习第二天
  • 搜索百科(1):Lucene —— 打开现代搜索世界的第一扇门