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

大家好,我是晓凡。

写在前面

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

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

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

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


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

反面教材

java 复制代码
@RestController
@RequestMapping("/getUserInfoByIdV2.3_beta")
public class UserController { ... }

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

正面写法

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

版本号放到 Header:Accept: application/vnd.myapp.v2+json

路由一旦上线,就是"墓碑",永远不许动,哪怕老板喊重构。


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

反面教材

java 复制代码
@PostMapping("/createOrder")
@PostMapping("/addOrder")
@PostMapping("/insertOrder")

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

正面写法

java 复制代码
@PostMapping("/orders")
public OrderDTO create(@RequestBody CreateOrderCommand cmd) { ... }

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


军规 3:统一用复数

反面教材

java 复制代码
@GetMapping("/order/{id}")
@GetMapping("/orders")

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

正面写法

java 复制代码
@GetMapping("/orders/{id}")
@GetMapping("/orders")

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


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

反面教材

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

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

正面写法

java 复制代码
@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();
}

返回统一包装:

java 复制代码
@Data
@Builder
public class PageResult<T> {
    private List<T> data;
    private long totalCount;
    private boolean hasNext;
}

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

反面教材

json 复制代码
{"userName":"Jack","userAge":18}

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

正面写法

java 复制代码
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class UserDTO {
    private String userName;
    private Integer userAge;
}

返回即:

json 复制代码
{"user_name":"Jack","user_age":18}

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


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

反面教材

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

DB 改个值,线上直接 500。

正面写法

java 复制代码
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:

java 复制代码
@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

反面教材

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

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

正面写法

java 复制代码
@PostMapping("/orders")
public OrderDTO create(@RequestBody @Valid CreateOrderCommand cmd) { ... }

@Data
public class CreateOrderCommand {
    @NotNull
    private Long skuId;
    @NotNull @Min(1)
    private Integer quantity;
}

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

json 复制代码
{"field":"quantity","message":"must be greater than or equal to 1"}

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

反面教材

java 复制代码
@GetMapping("/orders/{id}")
public OrderDTO get(@PathVariable Long id) { ... }

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

正面写法

java 复制代码
@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:错误码必须分段

反面教材

java 复制代码
new RuntimeException("订单不存在");

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

正面写法

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

全局异常处理:

java 复制代码
@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:接口必须幂等

反面教材

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

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

正面写法

java 复制代码
@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

反面教材

json 复制代码
{"createTime":"06/18/2025 09:05:12"}

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

正面写法

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

返回即:

json 复制代码
{"create_time":"2025-06-18T09:05:12+00:00"}

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


军规 12:Long 主键后端转 String

反面教材

json 复制代码
{"orderId":9223372036854775807}

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

正面写法

java 复制代码
@JsonSerialize(using = ToStringSerializer.class)
private Long orderId;

返回即:

json 复制代码
{"order_id":"9223372036854775807"}

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


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

反面教材

java 复制代码
@PostMapping("/orders/batch")
public List<OrderDTO> batch(@RequestBody List<Long> ids) { ... }

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

正面写法

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

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


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

反面教材

java 复制代码
@PostMapping("/upload")
public String upload(MultipartFile file) { ... }

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

正面写法

java 复制代码
@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:禁止把「内部错误码」直接抛给前端

反面教材

java 复制代码
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.对外错误码只保留「用户可理解」枚举,统一收敛:

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

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

java 复制代码
@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.前端拿到的是:

java 复制代码
{"code":5100,"msg":"商品库存不足"}

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

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


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

反面教材

java 复制代码
@RestController
public class OrderController {
    @PostMapping("/orders")
    public OrderDTO create(CreateOrderCommand cmd) { ... }
}

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

正面写法

java 复制代码
@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:关键接口必须打印入参出参

反面教材

java 复制代码
@PostMapping("/orders")
public OrderDTO create(@RequestBody CreateOrderCommand cmd) {
    return orderService.create(cmd);
}

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

正面写法

java 复制代码
@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 异步 + 脱敏:

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

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


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

反面教材

java 复制代码
// V1
public class UserDTO { private String name; }

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

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

正面写法

  1. 加新字段,不动旧字段:
java 复制代码
public class UserDTO {
    private String name;        //  deprecated
    private String username;    //  新字段
}
  1. 使用 @Deprecated 注解,Swagger 自动标灰。
  2. 配套单元测试:
java 复制代码
@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 (●'◡'●)

相关推荐
ZIM学编程2 分钟前
「学长有话说」作为一个大三学长,我想对大一计算机专业学生说这些!
java·c语言·数据结构·c++·python·学习·php
Dolphin_Home8 分钟前
轻量实用的 XML 与 JSON / 对象互转工具类(Jackson 实现)
xml·java·json
Yeniden25 分钟前
【设计模式】# 外观模式(Facade)大白话讲解!
java·设计模式·外观模式
脚踏实地的大梦想家26 分钟前
【Go】P17 Go语言并发编程核心:深入理解 Goroutine (从入门到实战)
java·开发语言·golang
Yeniden26 分钟前
【设计模式】 组合模式(Composite)大白话讲解
java·设计模式·组合模式
初学小白...31 分钟前
线程同步机制及三大不安全案例
java·开发语言·jvm
CS Beginner1 小时前
【搭建】个人博客网站的搭建
java·前端·学习·servlet·log4j·mybatis
JavaTree20171 小时前
【Spring Boot】Spring Boot解决循环依赖
java·spring boot·后端
lang201509282 小时前
Maven 五分钟入门
java·maven
cj6341181502 小时前
SpringBoot配置Redis
java·后端