[{"data":1,"prerenderedAt":3659},["ShallowReactive",2],{"post:\u002Fposts\u002Fjavaspring\u002Fspring-notes-11":3,"posts:ordered":3382},{"id":4,"title":5,"body":6,"category":3366,"cover":3367,"date":3368,"description":3369,"extension":3370,"featured":3371,"meta":3372,"navigation":299,"path":3373,"seo":3374,"series":3375,"seriesOrder":368,"stem":3376,"tags":3377,"__hash__":3381},"posts\u002Fposts\u002Fjavaspring\u002Fspring-notes-11.md","Spring 学习笔记 11：REST API 基础——把 Service 暴露成接口",{"type":7,"value":8,"toc":3354},"minimark",[9,16,21,24,37,61,65,68,130,141,145,148,243,249,253,272,1014,1017,1084,1087,1245,1280,1298,1302,1305,1357,1360,1494,1498,1508,1820,1823,1885,1904,1908,1911,2496,2514,2527,2531,2541,2997,3000,3056,3077,3081,3272,3276,3347,3350],[10,11,12],"blockquote",{},[13,14,15],"p",{},"这是一份个人学习笔记：概念部分是我按自己的理解写的，示例代码都是自己敲的，并跑过测试，\n参考的是 Spring 官方文档与 RFC 9110（HTTP 语义）。理解有偏差的地方欢迎指出。",[17,18,20],"h2",{"id":19},"_0-同一份业务多一种出口","0 同一份业务，多一种出口",[13,22,23],{},"前面的页面型应用只服务浏览器：服务端渲染 HTML，数据留在服务器上。但只要出现下面任何一种\n需求，这条路就不够了：",[25,26,27,31,34],"ul",{},[28,29,30],"li",{},"手机 App 要数据，不想要 HTML；",[28,32,33],{},"前端换成 React\u002FVue，渲染在客户端做；",[28,35,36],{},"第三方系统要对接，需要一份稳定的接口约定。",[13,38,39,43,44,48,49,52,53,56,57,60],{},[40,41,42],"strong",{},"服务端渲染和 REST API 不是二选一","。注意示例工程的结构：",[45,46,47],"code",{},"BookService"," 一个都没改，\n",[45,50,51],{},"BookViewController"," 继续渲染页面，新加的 ",[45,54,55],{},"BookController"," 只是又一层薄薄的适配。",[40,58,59],{},"业务逻辑\n只写一遍","——这才是分层的回报。",[17,62,64],{"id":63},"_1-rest-的四条约定","1 REST 的四条约定",[13,66,67],{},"REST 不是某个框架，而是一组设计约定。落到代码上，主要看这四条：",[69,70,71,84],"table",{},[72,73,74],"thead",{},[75,76,77,81],"tr",{},[78,79,80],"th",{},"约定",[78,82,83],{},"说明",[85,86,87,106,114,122],"tbody",{},[75,88,89,93],{},[90,91,92],"td",{},"用名词表示资源",[90,94,95,98,99,102,103],{},[45,96,97],{},"\u002Fapi\u002Fbooks","、",[45,100,101],{},"\u002Fapi\u002Fbooks\u002F1","，而不是 ",[45,104,105],{},"\u002FgetBookById",[75,107,108,111],{},[90,109,110],{},"用 HTTP 方法表意",[90,112,113],{},"GET 查、POST 建、PUT\u002FPATCH 改、DELETE 删，路径里不出现动词",[75,115,116,119],{},[90,117,118],{},"状态码表达结果",[90,120,121],{},"200\u002F201\u002F204\u002F400\u002F404\u002F409，别用「200 + 自定义 code」糊过去",[75,123,124,127],{},[90,125,126],{},"无状态",[90,128,129],{},"每个请求自带全部信息，服务端不靠会话维持客户端状态",[13,131,132,133,136,137,140],{},"第四点和第 08 篇的会话是两种思路：页面应用靠会话记住「谁登录了」，REST 接口通常要求每个请求\n自带凭证（比如 ",[45,134,135],{},"Authorization"," 头）。",[40,138,139],{},"别把会话里的登录态当成 API 的认证方式","——那样接口就\n不无状态了，也解释不了移动端为什么不带 Cookie 也能用。",[17,142,144],{"id":143},"_2-方法与幂等","2 方法与幂等",[13,146,147],{},"先记住「安全」和「幂等」这两个词，接口设计时反复用到：",[69,149,150,166],{},[72,151,152],{},[75,153,154,157,160,163],{},[78,155,156],{},"方法",[78,158,159],{},"用途",[78,161,162],{},"安全（不改数据）",[78,164,165],{},"幂等（调 N 次等于调 1 次）",[85,167,168,183,199,213,228],{},[75,169,170,175,178,181],{},[90,171,172],{},[45,173,174],{},"GET",[90,176,177],{},"查询",[90,179,180],{},"是",[90,182,180],{},[75,184,185,190,193,196],{},[90,186,187],{},[45,188,189],{},"POST",[90,191,192],{},"新建",[90,194,195],{},"否",[90,197,198],{},"否（重复提交会多建一条）",[75,200,201,206,209,211],{},[90,202,203],{},[45,204,205],{},"PUT",[90,207,208],{},"整体替换",[90,210,195],{},[90,212,180],{},[75,214,215,220,223,225],{},[90,216,217],{},[45,218,219],{},"PATCH",[90,221,222],{},"局部修改",[90,224,195],{},[90,226,227],{},"通常不是（除非设计成幂等）",[75,229,230,235,238,240],{},[90,231,232],{},[45,233,234],{},"DELETE",[90,236,237],{},"删除",[90,239,195],{},[90,241,242],{},"是（删两次结果一样）",[13,244,245,248],{},[40,246,247],{},"幂等这件事直接影响线上行为","：写操作不是幂等的时候，客户端超时重试就会重复下单。第 12 篇\n会讲怎么用版本号\u002FETag 把更新做成可安全重试的。",[17,250,252],{"id":251},"_3-restcontroller-与路由","3 @RestController 与路由",[13,254,255,256,259,260,263,264,267,268,271],{},"回到代码。",[45,257,258],{},"@RestController"," 就是 ",[45,261,262],{},"@Controller"," + ",[45,265,266],{},"@ResponseBody","，方法返回值",[40,269,270],{},"直接写进响应体","\n（对象由 Jackson 序列化成 JSON），不经过视图解析：",[273,274,279],"pre",{"className":275,"code":276,"language":277,"meta":278,"style":278},"language-java shiki shiki-themes github-light github-dark","package com.example.library.web;\n\nimport com.example.library.domain.Book;\nimport com.example.library.domain.BookStatus;\nimport com.example.library.dto.BookResponse;\nimport com.example.library.dto.BookTitleDto;\nimport com.example.library.service.BookService;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PatchMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.bind.annotation.ResponseStatus;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport java.util.List;\n\n@RestController\n@RequestMapping(\"\u002Fapi\u002Fbooks\")\npublic class BookController {\n\n    private final BookService bookService;\n\n    public BookController(BookService bookService) {\n        this.bookService = bookService;\n    }\n\n    @GetMapping\n    public Page\u003CBookTitleDto> list(@RequestParam(name = \"keyword\", required = false) String keyword,\n                                   Pageable pageable) {\n        return bookService.searchTitles(keyword, pageable);\n    }\n\n    @GetMapping(\"\u002F{id}\")\n    public BookResponse get(@PathVariable Long id) {\n        return bookService.toResponse(bookService.findById(id));\n    }\n\n    @GetMapping(\"\u002Fsummaries\")\n    public List\u003CBookTitleDto> summaries() {\n        return bookService.availableSummaries();\n    }\n\n    @PostMapping\n    @ResponseStatus(HttpStatus.CREATED)\n    public BookResponse add(@RequestBody CreateBookRequest request) {\n        Book book = bookService.add(request.title(), request.author());\n        return bookService.toResponse(book);\n    }\n\n    @PatchMapping(\"\u002F{id}\u002Fstatus\")\n    public BookResponse changeStatus(@PathVariable Long id, @RequestBody ChangeStatusRequest request) {\n        bookService.markStatus(id, request.status());\n        return bookService.toResponse(bookService.findById(id));\n    }\n\n    public record CreateBookRequest(String title, String author) {\n    }\n\n    public record ChangeStatusRequest(BookStatus status) {\n    }\n}\n","java","",[45,280,281,294,301,310,318,326,334,342,350,358,366,374,382,390,398,406,414,422,430,438,443,451,456,465,483,499,504,516,521,539,555,561,566,575,629,640,655,660,665,680,704,723,728,733,747,765,778,783,788,796,807,830,857,869,874,879,894,924,941,956,961,966,980,985,990,1003,1008],{"__ignoreMap":278},[282,283,286,290],"span",{"class":284,"line":285},"line",1,[282,287,289],{"class":288},"szBVR","package",[282,291,293],{"class":292},"sVt8B"," com.example.library.web;\n",[282,295,297],{"class":284,"line":296},2,[282,298,300],{"emptyLinePlaceholder":299},true,"\n",[282,302,304,307],{"class":284,"line":303},3,[282,305,306],{"class":288},"import",[282,308,309],{"class":292}," com.example.library.domain.Book;\n",[282,311,313,315],{"class":284,"line":312},4,[282,314,306],{"class":288},[282,316,317],{"class":292}," com.example.library.domain.BookStatus;\n",[282,319,321,323],{"class":284,"line":320},5,[282,322,306],{"class":288},[282,324,325],{"class":292}," com.example.library.dto.BookResponse;\n",[282,327,329,331],{"class":284,"line":328},6,[282,330,306],{"class":288},[282,332,333],{"class":292}," com.example.library.dto.BookTitleDto;\n",[282,335,337,339],{"class":284,"line":336},7,[282,338,306],{"class":288},[282,340,341],{"class":292}," com.example.library.service.BookService;\n",[282,343,345,347],{"class":284,"line":344},8,[282,346,306],{"class":288},[282,348,349],{"class":292}," org.springframework.data.domain.Page;\n",[282,351,353,355],{"class":284,"line":352},9,[282,354,306],{"class":288},[282,356,357],{"class":292}," org.springframework.data.domain.Pageable;\n",[282,359,361,363],{"class":284,"line":360},10,[282,362,306],{"class":288},[282,364,365],{"class":292}," org.springframework.http.HttpStatus;\n",[282,367,369,371],{"class":284,"line":368},11,[282,370,306],{"class":288},[282,372,373],{"class":292}," org.springframework.web.bind.annotation.GetMapping;\n",[282,375,377,379],{"class":284,"line":376},12,[282,378,306],{"class":288},[282,380,381],{"class":292}," org.springframework.web.bind.annotation.PatchMapping;\n",[282,383,385,387],{"class":284,"line":384},13,[282,386,306],{"class":288},[282,388,389],{"class":292}," org.springframework.web.bind.annotation.PathVariable;\n",[282,391,393,395],{"class":284,"line":392},14,[282,394,306],{"class":288},[282,396,397],{"class":292}," org.springframework.web.bind.annotation.PostMapping;\n",[282,399,401,403],{"class":284,"line":400},15,[282,402,306],{"class":288},[282,404,405],{"class":292}," org.springframework.web.bind.annotation.RequestBody;\n",[282,407,409,411],{"class":284,"line":408},16,[282,410,306],{"class":288},[282,412,413],{"class":292}," org.springframework.web.bind.annotation.RequestMapping;\n",[282,415,417,419],{"class":284,"line":416},17,[282,418,306],{"class":288},[282,420,421],{"class":292}," org.springframework.web.bind.annotation.RequestParam;\n",[282,423,425,427],{"class":284,"line":424},18,[282,426,306],{"class":288},[282,428,429],{"class":292}," org.springframework.web.bind.annotation.ResponseStatus;\n",[282,431,433,435],{"class":284,"line":432},19,[282,434,306],{"class":288},[282,436,437],{"class":292}," org.springframework.web.bind.annotation.RestController;\n",[282,439,441],{"class":284,"line":440},20,[282,442,300],{"emptyLinePlaceholder":299},[282,444,446,448],{"class":284,"line":445},21,[282,447,306],{"class":288},[282,449,450],{"class":292}," java.util.List;\n",[282,452,454],{"class":284,"line":453},22,[282,455,300],{"emptyLinePlaceholder":299},[282,457,459,462],{"class":284,"line":458},23,[282,460,461],{"class":292},"@",[282,463,464],{"class":288},"RestController\n",[282,466,468,470,473,476,480],{"class":284,"line":467},24,[282,469,461],{"class":292},[282,471,472],{"class":288},"RequestMapping",[282,474,475],{"class":292},"(",[282,477,479],{"class":478},"sZZnC","\"\u002Fapi\u002Fbooks\"",[282,481,482],{"class":292},")\n",[282,484,486,489,492,496],{"class":284,"line":485},25,[282,487,488],{"class":288},"public",[282,490,491],{"class":288}," class",[282,493,495],{"class":494},"sScJk"," BookController",[282,497,498],{"class":292}," {\n",[282,500,502],{"class":284,"line":501},26,[282,503,300],{"emptyLinePlaceholder":299},[282,505,507,510,513],{"class":284,"line":506},27,[282,508,509],{"class":288},"    private",[282,511,512],{"class":288}," final",[282,514,515],{"class":292}," BookService bookService;\n",[282,517,519],{"class":284,"line":518},28,[282,520,300],{"emptyLinePlaceholder":299},[282,522,524,527,529,532,536],{"class":284,"line":523},29,[282,525,526],{"class":288},"    public",[282,528,495],{"class":494},[282,530,531],{"class":292},"(BookService ",[282,533,535],{"class":534},"s4XuR","bookService",[282,537,538],{"class":292},") {\n",[282,540,542,546,549,552],{"class":284,"line":541},30,[282,543,545],{"class":544},"sj4cs","        this",[282,547,548],{"class":292},".bookService ",[282,550,551],{"class":288},"=",[282,553,554],{"class":292}," bookService;\n",[282,556,558],{"class":284,"line":557},31,[282,559,560],{"class":292},"    }\n",[282,562,564],{"class":284,"line":563},32,[282,565,300],{"emptyLinePlaceholder":299},[282,567,569,572],{"class":284,"line":568},33,[282,570,571],{"class":292},"    @",[282,573,574],{"class":288},"GetMapping\n",[282,576,578,580,583,586,589,592,595,598,600,603,606,609,612,615,617,620,623,626],{"class":284,"line":577},34,[282,579,526],{"class":288},[282,581,582],{"class":292}," Page\u003C",[282,584,585],{"class":288},"BookTitleDto",[282,587,588],{"class":292},"> ",[282,590,591],{"class":494},"list",[282,593,594],{"class":292},"(@",[282,596,597],{"class":288},"RequestParam",[282,599,475],{"class":292},[282,601,602],{"class":544},"name",[282,604,605],{"class":288}," =",[282,607,608],{"class":478}," \"keyword\"",[282,610,611],{"class":292},", ",[282,613,614],{"class":544},"required",[282,616,605],{"class":288},[282,618,619],{"class":544}," false",[282,621,622],{"class":292},") String ",[282,624,625],{"class":534},"keyword",[282,627,628],{"class":292},",\n",[282,630,632,635,638],{"class":284,"line":631},35,[282,633,634],{"class":292},"                                   Pageable ",[282,636,637],{"class":534},"pageable",[282,639,538],{"class":292},[282,641,643,646,649,652],{"class":284,"line":642},36,[282,644,645],{"class":288},"        return",[282,647,648],{"class":292}," bookService.",[282,650,651],{"class":494},"searchTitles",[282,653,654],{"class":292},"(keyword, pageable);\n",[282,656,658],{"class":284,"line":657},37,[282,659,560],{"class":292},[282,661,663],{"class":284,"line":662},38,[282,664,300],{"emptyLinePlaceholder":299},[282,666,668,670,673,675,678],{"class":284,"line":667},39,[282,669,571],{"class":292},[282,671,672],{"class":288},"GetMapping",[282,674,475],{"class":292},[282,676,677],{"class":478},"\"\u002F{id}\"",[282,679,482],{"class":292},[282,681,683,685,688,691,693,696,699,702],{"class":284,"line":682},40,[282,684,526],{"class":288},[282,686,687],{"class":292}," BookResponse ",[282,689,690],{"class":494},"get",[282,692,594],{"class":292},[282,694,695],{"class":288},"PathVariable",[282,697,698],{"class":292}," Long ",[282,700,701],{"class":534},"id",[282,703,538],{"class":292},[282,705,707,709,711,714,717,720],{"class":284,"line":706},41,[282,708,645],{"class":288},[282,710,648],{"class":292},[282,712,713],{"class":494},"toResponse",[282,715,716],{"class":292},"(bookService.",[282,718,719],{"class":494},"findById",[282,721,722],{"class":292},"(id));\n",[282,724,726],{"class":284,"line":725},42,[282,727,560],{"class":292},[282,729,731],{"class":284,"line":730},43,[282,732,300],{"emptyLinePlaceholder":299},[282,734,736,738,740,742,745],{"class":284,"line":735},44,[282,737,571],{"class":292},[282,739,672],{"class":288},[282,741,475],{"class":292},[282,743,744],{"class":478},"\"\u002Fsummaries\"",[282,746,482],{"class":292},[282,748,750,752,755,757,759,762],{"class":284,"line":749},45,[282,751,526],{"class":288},[282,753,754],{"class":292}," List\u003C",[282,756,585],{"class":288},[282,758,588],{"class":292},[282,760,761],{"class":494},"summaries",[282,763,764],{"class":292},"() {\n",[282,766,768,770,772,775],{"class":284,"line":767},46,[282,769,645],{"class":288},[282,771,648],{"class":292},[282,773,774],{"class":494},"availableSummaries",[282,776,777],{"class":292},"();\n",[282,779,781],{"class":284,"line":780},47,[282,782,560],{"class":292},[282,784,786],{"class":284,"line":785},48,[282,787,300],{"emptyLinePlaceholder":299},[282,789,791,793],{"class":284,"line":790},49,[282,792,571],{"class":292},[282,794,795],{"class":288},"PostMapping\n",[282,797,799,801,804],{"class":284,"line":798},50,[282,800,571],{"class":292},[282,802,803],{"class":288},"ResponseStatus",[282,805,806],{"class":292},"(HttpStatus.CREATED)\n",[282,808,810,812,814,817,819,822,825,828],{"class":284,"line":809},51,[282,811,526],{"class":288},[282,813,687],{"class":292},[282,815,816],{"class":494},"add",[282,818,594],{"class":292},[282,820,821],{"class":288},"RequestBody",[282,823,824],{"class":292}," CreateBookRequest ",[282,826,827],{"class":534},"request",[282,829,538],{"class":292},[282,831,833,836,838,840,842,845,848,851,854],{"class":284,"line":832},52,[282,834,835],{"class":292},"        Book book ",[282,837,551],{"class":288},[282,839,648],{"class":292},[282,841,816],{"class":494},[282,843,844],{"class":292},"(request.",[282,846,847],{"class":494},"title",[282,849,850],{"class":292},"(), request.",[282,852,853],{"class":494},"author",[282,855,856],{"class":292},"());\n",[282,858,860,862,864,866],{"class":284,"line":859},53,[282,861,645],{"class":288},[282,863,648],{"class":292},[282,865,713],{"class":494},[282,867,868],{"class":292},"(book);\n",[282,870,872],{"class":284,"line":871},54,[282,873,560],{"class":292},[282,875,877],{"class":284,"line":876},55,[282,878,300],{"emptyLinePlaceholder":299},[282,880,882,884,887,889,892],{"class":284,"line":881},56,[282,883,571],{"class":292},[282,885,886],{"class":288},"PatchMapping",[282,888,475],{"class":292},[282,890,891],{"class":478},"\"\u002F{id}\u002Fstatus\"",[282,893,482],{"class":292},[282,895,897,899,901,904,906,908,910,912,915,917,920,922],{"class":284,"line":896},57,[282,898,526],{"class":288},[282,900,687],{"class":292},[282,902,903],{"class":494},"changeStatus",[282,905,594],{"class":292},[282,907,695],{"class":288},[282,909,698],{"class":292},[282,911,701],{"class":534},[282,913,914],{"class":292},", @",[282,916,821],{"class":288},[282,918,919],{"class":292}," ChangeStatusRequest ",[282,921,827],{"class":534},[282,923,538],{"class":292},[282,925,927,930,933,936,939],{"class":284,"line":926},58,[282,928,929],{"class":292},"        bookService.",[282,931,932],{"class":494},"markStatus",[282,934,935],{"class":292},"(id, request.",[282,937,938],{"class":494},"status",[282,940,856],{"class":292},[282,942,944,946,948,950,952,954],{"class":284,"line":943},59,[282,945,645],{"class":288},[282,947,648],{"class":292},[282,949,713],{"class":494},[282,951,716],{"class":292},[282,953,719],{"class":494},[282,955,722],{"class":292},[282,957,959],{"class":284,"line":958},60,[282,960,560],{"class":292},[282,962,964],{"class":284,"line":963},61,[282,965,300],{"emptyLinePlaceholder":299},[282,967,969,971,974,977],{"class":284,"line":968},62,[282,970,526],{"class":288},[282,972,973],{"class":288}," record",[282,975,976],{"class":494}," CreateBookRequest",[282,978,979],{"class":292},"(String title, String author) {\n",[282,981,983],{"class":284,"line":982},63,[282,984,560],{"class":292},[282,986,988],{"class":284,"line":987},64,[282,989,300],{"emptyLinePlaceholder":299},[282,991,993,995,997,1000],{"class":284,"line":992},65,[282,994,526],{"class":288},[282,996,973],{"class":288},[282,998,999],{"class":494}," ChangeStatusRequest",[282,1001,1002],{"class":292},"(BookStatus status) {\n",[282,1004,1006],{"class":284,"line":1005},66,[282,1007,560],{"class":292},[282,1009,1011],{"class":284,"line":1010},67,[282,1012,1013],{"class":292},"}\n",[13,1015,1016],{},"几处设计选择：",[25,1018,1019,1028,1047,1072],{},[28,1020,1021,1027],{},[40,1022,1023,1024],{},"路径统一带前缀 ",[45,1025,1026],{},"\u002Fapi","。这样后面加 Nginx 路由、区分页面与接口、给接口单独配认证都方便；",[28,1029,1030,1037,1038,263,1040,1042,1043,1046],{},[40,1031,1032,1033,1036],{},"类级别 ",[45,1034,1035],{},"@RequestMapping"," 写资源，方法级别写动作","：",[45,1039,97],{},[45,1041,174],{},"，不要再出现\n",[45,1044,1045],{},"getBookById"," 这种名字；",[28,1048,1049,1055,1056,98,1059,1062,1063,1066,1067,98,1069,1071],{},[40,1050,1051,1052],{},"请求体用小 ",[45,1053,1054],{},"record","（",[45,1057,1058],{},"CreateBookRequest",[45,1060,1061],{},"ChangeStatusRequest","），声明即文档，\n也不用为它写 setter。",[40,1064,1065],{},"不要直接拿实体当请求体","——客户端就能改到 ",[45,1068,701],{},[45,1070,938],{}," 这些\n不该由它决定的字段（第 12 篇展开「批量赋值」风险）；",[28,1073,1074,1037,1077,1080,1081,1083],{},[40,1075,1076],{},"返回 DTO 而不是实体",[45,1078,1079],{},"BookResponse"," \u002F ",[45,1082,585],{}," 只带该给的字段，顺带绕开延迟加载\n字段被序列化时的意外触发。",[13,1085,1086],{},"那两个 DTO 长这样：",[273,1088,1090],{"className":275,"code":1089,"language":277,"meta":278,"style":278},"package com.example.library.dto;\n\nimport com.example.library.domain.Book;\nimport com.example.library.domain.BookStatus;\n\n\u002F** 对外返回的图书表示：不用把实体直接暴露给客户端 *\u002F\npublic record BookResponse(Long id, String title, String author, BookStatus status, String categoryName) {\n\n    public static BookResponse of(Book book) {\n        String categoryName = book.getCategory() == null ? null : book.getCategory().getName();\n        return new BookResponse(book.getId(), book.getTitle(), book.getAuthor(), book.getStatus(), categoryName);\n    }\n}\n",[45,1091,1092,1099,1103,1109,1115,1119,1125,1137,1141,1161,1203,1237,1241],{"__ignoreMap":278},[282,1093,1094,1096],{"class":284,"line":285},[282,1095,289],{"class":288},[282,1097,1098],{"class":292}," com.example.library.dto;\n",[282,1100,1101],{"class":284,"line":296},[282,1102,300],{"emptyLinePlaceholder":299},[282,1104,1105,1107],{"class":284,"line":303},[282,1106,306],{"class":288},[282,1108,309],{"class":292},[282,1110,1111,1113],{"class":284,"line":312},[282,1112,306],{"class":288},[282,1114,317],{"class":292},[282,1116,1117],{"class":284,"line":320},[282,1118,300],{"emptyLinePlaceholder":299},[282,1120,1121],{"class":284,"line":328},[282,1122,1124],{"class":1123},"sJ8bj","\u002F** 对外返回的图书表示：不用把实体直接暴露给客户端 *\u002F\n",[282,1126,1127,1129,1131,1134],{"class":284,"line":336},[282,1128,488],{"class":288},[282,1130,973],{"class":288},[282,1132,1133],{"class":494}," BookResponse",[282,1135,1136],{"class":292},"(Long id, String title, String author, BookStatus status, String categoryName) {\n",[282,1138,1139],{"class":284,"line":344},[282,1140,300],{"emptyLinePlaceholder":299},[282,1142,1143,1145,1148,1150,1153,1156,1159],{"class":284,"line":352},[282,1144,526],{"class":288},[282,1146,1147],{"class":288}," static",[282,1149,687],{"class":292},[282,1151,1152],{"class":494},"of",[282,1154,1155],{"class":292},"(Book ",[282,1157,1158],{"class":534},"book",[282,1160,538],{"class":292},[282,1162,1163,1166,1168,1171,1174,1177,1180,1183,1186,1188,1191,1193,1195,1198,1201],{"class":284,"line":360},[282,1164,1165],{"class":292},"        String categoryName ",[282,1167,551],{"class":288},[282,1169,1170],{"class":292}," book.",[282,1172,1173],{"class":494},"getCategory",[282,1175,1176],{"class":292},"() ",[282,1178,1179],{"class":288},"==",[282,1181,1182],{"class":544}," null",[282,1184,1185],{"class":288}," ?",[282,1187,1182],{"class":544},[282,1189,1190],{"class":288}," :",[282,1192,1170],{"class":292},[282,1194,1173],{"class":494},[282,1196,1197],{"class":292},"().",[282,1199,1200],{"class":494},"getName",[282,1202,777],{"class":292},[282,1204,1205,1207,1210,1212,1215,1218,1221,1224,1226,1229,1231,1234],{"class":284,"line":368},[282,1206,645],{"class":288},[282,1208,1209],{"class":288}," new",[282,1211,1133],{"class":494},[282,1213,1214],{"class":292},"(book.",[282,1216,1217],{"class":494},"getId",[282,1219,1220],{"class":292},"(), book.",[282,1222,1223],{"class":494},"getTitle",[282,1225,1220],{"class":292},[282,1227,1228],{"class":494},"getAuthor",[282,1230,1220],{"class":292},[282,1232,1233],{"class":494},"getStatus",[282,1235,1236],{"class":292},"(), categoryName);\n",[282,1238,1239],{"class":284,"line":376},[282,1240,560],{"class":292},[282,1242,1243],{"class":284,"line":384},[282,1244,1013],{"class":292},[273,1246,1248],{"className":275,"code":1247,"language":277,"meta":278,"style":278},"package com.example.library.dto;\n\n\u002F** DTO 构造表达式需要它在 JPQL 里写成全限定类名（见第 03 篇） *\u002F\npublic record BookTitleDto(String title, String author) {\n}\n",[45,1249,1250,1256,1260,1265,1276],{"__ignoreMap":278},[282,1251,1252,1254],{"class":284,"line":285},[282,1253,289],{"class":288},[282,1255,1098],{"class":292},[282,1257,1258],{"class":284,"line":296},[282,1259,300],{"emptyLinePlaceholder":299},[282,1261,1262],{"class":284,"line":303},[282,1263,1264],{"class":1123},"\u002F** DTO 构造表达式需要它在 JPQL 里写成全限定类名（见第 03 篇） *\u002F\n",[282,1266,1267,1269,1271,1274],{"class":284,"line":312},[282,1268,488],{"class":288},[282,1270,973],{"class":288},[282,1272,1273],{"class":494}," BookTitleDto",[282,1275,979],{"class":292},[282,1277,1278],{"class":284,"line":320},[282,1279,1013],{"class":292},[13,1281,1282,1285,1286,1289,1290,1293,1294,1297],{},[45,1283,1284],{},"BookResponse.of(book)"," 在 Service 的事务里调用（",[45,1287,1288],{},"BookServiceImpl.toResponse","），这样访问\n",[45,1291,1292],{},"book.getCategory().getName()"," 时事务还在，不会撞上 ",[45,1295,1296],{},"LazyInitializationException","。",[17,1299,1301],{"id":1300},"_4-状态码怎么给","4 状态码怎么给",[13,1303,1304],{},"三种写法，按需要选：",[69,1306,1307,1317],{},[72,1308,1309],{},[75,1310,1311,1314],{},[78,1312,1313],{},"场景",[78,1315,1316],{},"写法",[85,1318,1319,1330,1345],{},[75,1320,1321,1324],{},[90,1322,1323],{},"固定返回某个状态码",[90,1325,1326,1327],{},"方法上 ",[45,1328,1329],{},"@ResponseStatus(HttpStatus.CREATED)",[75,1331,1332,1335],{},[90,1333,1334],{},"状态码要按运行时情况决定",[90,1336,1337,1338,1341,1342],{},"返回 ",[45,1339,1340],{},"ResponseEntity\u003CT>","，",[45,1343,1344],{},"ResponseEntity.status(...).body(...)",[75,1346,1347,1350],{},[90,1348,1349],{},"出错了要返回结构化错误信息",[90,1351,1352,1353,1356],{},"抛异常 + ",[45,1354,1355],{},"@RestControllerAdvice","（见下节）",[13,1358,1359],{},"常用状态码对照：",[69,1361,1362,1375],{},[72,1363,1364],{},[75,1365,1366,1369,1372],{},[78,1367,1368],{},"码",[78,1370,1371],{},"含义",[78,1373,1374],{},"常见场景",[85,1376,1377,1388,1403,1414,1425,1436,1447,1458,1469,1480],{},[75,1378,1379,1382,1385],{},[90,1380,1381],{},"200 OK",[90,1383,1384],{},"成功",[90,1386,1387],{},"GET、PATCH 成功",[75,1389,1390,1393,1396],{},[90,1391,1392],{},"201 Created",[90,1394,1395],{},"已创建",[90,1397,1398,1399,1402],{},"POST 成功；配合 ",[45,1400,1401],{},"Location"," 头更规范",[75,1404,1405,1408,1411],{},[90,1406,1407],{},"204 No Content",[90,1409,1410],{},"成功但无响应体",[90,1412,1413],{},"DELETE 成功",[75,1415,1416,1419,1422],{},[90,1417,1418],{},"400 Bad Request",[90,1420,1421],{},"请求不合法",[90,1423,1424],{},"参数格式错、校验不通过",[75,1426,1427,1430,1433],{},[90,1428,1429],{},"401 Unauthorized",[90,1431,1432],{},"未认证",[90,1434,1435],{},"没带或带了无效凭证",[75,1437,1438,1441,1444],{},[90,1439,1440],{},"403 Forbidden",[90,1442,1443],{},"已认证但无权限",[90,1445,1446],{},"普通用户调管理员接口",[75,1448,1449,1452,1455],{},[90,1450,1451],{},"404 Not Found",[90,1453,1454],{},"资源不存在",[90,1456,1457],{},"id 查不到",[75,1459,1460,1463,1466],{},[90,1461,1462],{},"409 Conflict",[90,1464,1465],{},"与当前状态冲突",[90,1467,1468],{},"重复创建、状态不允许（书已借出）",[75,1470,1471,1474,1477],{},[90,1472,1473],{},"422 Unprocessable",[90,1475,1476],{},"语义有误",[90,1478,1479],{},"有些团队用它区分「格式对但业务不合法」",[75,1481,1482,1485,1488],{},[90,1483,1484],{},"500",[90,1486,1487],{},"服务端错误",[90,1489,1490,1491],{},"未捕获异常，",[40,1492,1493],{},"不该暴露堆栈给客户端",[17,1495,1497],{"id":1496},"_5-错误响应用-problemdetail","5 错误响应用 ProblemDetail",[13,1499,1500,1501,1504,1505,1507],{},"错误信息最忌讳一个接口一套格式。Spring 6 提供了对应 RFC 7807 的 ",[45,1502,1503],{},"ProblemDetail","，\n配合 ",[45,1506,1355],{}," 就能统一：",[273,1509,1511],{"className":275,"code":1510,"language":277,"meta":278,"style":278},"package com.example.library.web;\n\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.ProblemDetail;\nimport org.springframework.web.bind.annotation.ExceptionHandler;\nimport org.springframework.web.bind.annotation.RestControllerAdvice;\n\nimport java.util.NoSuchElementException;\n\n\u002F** REST 接口的统一异常处理：返回 RFC 7807 风格的问题详情 *\u002F\n@RestControllerAdvice(assignableTypes = BookController.class)\npublic class ApiExceptionHandler {\n\n    @ExceptionHandler(NoSuchElementException.class)\n    public ProblemDetail handleNotFound(NoSuchElementException ex) {\n        ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());\n        detail.setTitle(\"资源不存在\");\n        return detail;\n    }\n\n    @ExceptionHandler(IllegalArgumentException.class)\n    public ProblemDetail handleBadRequest(IllegalArgumentException ex) {\n        ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());\n        detail.setTitle(\"请求参数不合法\");\n        return detail;\n    }\n\n    @ExceptionHandler(IllegalStateException.class)\n    public ProblemDetail handleConflict(IllegalStateException ex) {\n        ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());\n        detail.setTitle(\"当前状态不允许该操作\");\n        return detail;\n    }\n}\n",[45,1512,1513,1519,1523,1529,1536,1543,1550,1554,1561,1565,1570,1587,1598,1602,1612,1630,1651,1667,1674,1678,1682,1691,1707,1724,1737,1743,1747,1751,1760,1776,1793,1806,1812,1816],{"__ignoreMap":278},[282,1514,1515,1517],{"class":284,"line":285},[282,1516,289],{"class":288},[282,1518,293],{"class":292},[282,1520,1521],{"class":284,"line":296},[282,1522,300],{"emptyLinePlaceholder":299},[282,1524,1525,1527],{"class":284,"line":303},[282,1526,306],{"class":288},[282,1528,365],{"class":292},[282,1530,1531,1533],{"class":284,"line":312},[282,1532,306],{"class":288},[282,1534,1535],{"class":292}," org.springframework.http.ProblemDetail;\n",[282,1537,1538,1540],{"class":284,"line":320},[282,1539,306],{"class":288},[282,1541,1542],{"class":292}," org.springframework.web.bind.annotation.ExceptionHandler;\n",[282,1544,1545,1547],{"class":284,"line":328},[282,1546,306],{"class":288},[282,1548,1549],{"class":292}," org.springframework.web.bind.annotation.RestControllerAdvice;\n",[282,1551,1552],{"class":284,"line":336},[282,1553,300],{"emptyLinePlaceholder":299},[282,1555,1556,1558],{"class":284,"line":344},[282,1557,306],{"class":288},[282,1559,1560],{"class":292}," java.util.NoSuchElementException;\n",[282,1562,1563],{"class":284,"line":352},[282,1564,300],{"emptyLinePlaceholder":299},[282,1566,1567],{"class":284,"line":360},[282,1568,1569],{"class":1123},"\u002F** REST 接口的统一异常处理：返回 RFC 7807 风格的问题详情 *\u002F\n",[282,1571,1572,1574,1577,1579,1582,1584],{"class":284,"line":368},[282,1573,461],{"class":292},[282,1575,1576],{"class":288},"RestControllerAdvice",[282,1578,475],{"class":292},[282,1580,1581],{"class":544},"assignableTypes",[282,1583,605],{"class":288},[282,1585,1586],{"class":292}," BookController.class)\n",[282,1588,1589,1591,1593,1596],{"class":284,"line":376},[282,1590,488],{"class":288},[282,1592,491],{"class":288},[282,1594,1595],{"class":494}," ApiExceptionHandler",[282,1597,498],{"class":292},[282,1599,1600],{"class":284,"line":384},[282,1601,300],{"emptyLinePlaceholder":299},[282,1603,1604,1606,1609],{"class":284,"line":392},[282,1605,571],{"class":292},[282,1607,1608],{"class":288},"ExceptionHandler",[282,1610,1611],{"class":292},"(NoSuchElementException.class)\n",[282,1613,1614,1616,1619,1622,1625,1628],{"class":284,"line":400},[282,1615,526],{"class":288},[282,1617,1618],{"class":292}," ProblemDetail ",[282,1620,1621],{"class":494},"handleNotFound",[282,1623,1624],{"class":292},"(NoSuchElementException ",[282,1626,1627],{"class":534},"ex",[282,1629,538],{"class":292},[282,1631,1632,1635,1637,1640,1643,1646,1649],{"class":284,"line":408},[282,1633,1634],{"class":292},"        ProblemDetail detail ",[282,1636,551],{"class":288},[282,1638,1639],{"class":292}," ProblemDetail.",[282,1641,1642],{"class":494},"forStatusAndDetail",[282,1644,1645],{"class":292},"(HttpStatus.NOT_FOUND, ex.",[282,1647,1648],{"class":494},"getMessage",[282,1650,856],{"class":292},[282,1652,1653,1656,1659,1661,1664],{"class":284,"line":416},[282,1654,1655],{"class":292},"        detail.",[282,1657,1658],{"class":494},"setTitle",[282,1660,475],{"class":292},[282,1662,1663],{"class":478},"\"资源不存在\"",[282,1665,1666],{"class":292},");\n",[282,1668,1669,1671],{"class":284,"line":424},[282,1670,645],{"class":288},[282,1672,1673],{"class":292}," detail;\n",[282,1675,1676],{"class":284,"line":432},[282,1677,560],{"class":292},[282,1679,1680],{"class":284,"line":440},[282,1681,300],{"emptyLinePlaceholder":299},[282,1683,1684,1686,1688],{"class":284,"line":445},[282,1685,571],{"class":292},[282,1687,1608],{"class":288},[282,1689,1690],{"class":292},"(IllegalArgumentException.class)\n",[282,1692,1693,1695,1697,1700,1703,1705],{"class":284,"line":453},[282,1694,526],{"class":288},[282,1696,1618],{"class":292},[282,1698,1699],{"class":494},"handleBadRequest",[282,1701,1702],{"class":292},"(IllegalArgumentException ",[282,1704,1627],{"class":534},[282,1706,538],{"class":292},[282,1708,1709,1711,1713,1715,1717,1720,1722],{"class":284,"line":458},[282,1710,1634],{"class":292},[282,1712,551],{"class":288},[282,1714,1639],{"class":292},[282,1716,1642],{"class":494},[282,1718,1719],{"class":292},"(HttpStatus.BAD_REQUEST, ex.",[282,1721,1648],{"class":494},[282,1723,856],{"class":292},[282,1725,1726,1728,1730,1732,1735],{"class":284,"line":467},[282,1727,1655],{"class":292},[282,1729,1658],{"class":494},[282,1731,475],{"class":292},[282,1733,1734],{"class":478},"\"请求参数不合法\"",[282,1736,1666],{"class":292},[282,1738,1739,1741],{"class":284,"line":485},[282,1740,645],{"class":288},[282,1742,1673],{"class":292},[282,1744,1745],{"class":284,"line":501},[282,1746,560],{"class":292},[282,1748,1749],{"class":284,"line":506},[282,1750,300],{"emptyLinePlaceholder":299},[282,1752,1753,1755,1757],{"class":284,"line":518},[282,1754,571],{"class":292},[282,1756,1608],{"class":288},[282,1758,1759],{"class":292},"(IllegalStateException.class)\n",[282,1761,1762,1764,1766,1769,1772,1774],{"class":284,"line":523},[282,1763,526],{"class":288},[282,1765,1618],{"class":292},[282,1767,1768],{"class":494},"handleConflict",[282,1770,1771],{"class":292},"(IllegalStateException ",[282,1773,1627],{"class":534},[282,1775,538],{"class":292},[282,1777,1778,1780,1782,1784,1786,1789,1791],{"class":284,"line":541},[282,1779,1634],{"class":292},[282,1781,551],{"class":288},[282,1783,1639],{"class":292},[282,1785,1642],{"class":494},[282,1787,1788],{"class":292},"(HttpStatus.CONFLICT, ex.",[282,1790,1648],{"class":494},[282,1792,856],{"class":292},[282,1794,1795,1797,1799,1801,1804],{"class":284,"line":557},[282,1796,1655],{"class":292},[282,1798,1658],{"class":494},[282,1800,475],{"class":292},[282,1802,1803],{"class":478},"\"当前状态不允许该操作\"",[282,1805,1666],{"class":292},[282,1807,1808,1810],{"class":284,"line":563},[282,1809,645],{"class":288},[282,1811,1673],{"class":292},[282,1813,1814],{"class":284,"line":568},[282,1815,560],{"class":292},[282,1817,1818],{"class":284,"line":577},[282,1819,1013],{"class":292},[13,1821,1822],{},"返回体长这样（字段名是规范的固定部分）：",[273,1824,1828],{"className":1825,"code":1826,"language":1827,"meta":278,"style":278},"language-json shiki shiki-themes github-light github-dark","{\n  \"type\": \"about:blank\",\n  \"title\": \"资源不存在\",\n  \"status\": 404,\n  \"detail\": \"找不到图书：999\"\n}\n","json",[45,1829,1830,1835,1848,1859,1871,1881],{"__ignoreMap":278},[282,1831,1832],{"class":284,"line":285},[282,1833,1834],{"class":292},"{\n",[282,1836,1837,1840,1843,1846],{"class":284,"line":296},[282,1838,1839],{"class":544},"  \"type\"",[282,1841,1842],{"class":292},": ",[282,1844,1845],{"class":478},"\"about:blank\"",[282,1847,628],{"class":292},[282,1849,1850,1853,1855,1857],{"class":284,"line":303},[282,1851,1852],{"class":544},"  \"title\"",[282,1854,1842],{"class":292},[282,1856,1663],{"class":478},[282,1858,628],{"class":292},[282,1860,1861,1864,1866,1869],{"class":284,"line":312},[282,1862,1863],{"class":544},"  \"status\"",[282,1865,1842],{"class":292},[282,1867,1868],{"class":544},"404",[282,1870,628],{"class":292},[282,1872,1873,1876,1878],{"class":284,"line":320},[282,1874,1875],{"class":544},"  \"detail\"",[282,1877,1842],{"class":292},[282,1879,1880],{"class":478},"\"找不到图书：999\"\n",[282,1882,1883],{"class":284,"line":328},[282,1884,1013],{"class":292},[13,1886,1887,1893,1894,1896,1897,1900,1901,1903],{},[40,1888,1889,1892],{},[45,1890,1891],{},"assignableTypes = BookController.class"," 这个限定很重要","。不加限定的话，所有控制器（包括\n页面型的 ",[45,1895,51],{},"）的异常都会被这个 advice 接走，返回 JSON 给浏览器——用户看到的\n就不是错误页面而是一段 JSON 了。第 06 篇的 ",[45,1898,1899],{},"GlobalExceptionHandler"," 用 ",[45,1902,1581],{},"\n限定在页面控制器上，两者各管一段，互不干扰。",[17,1905,1907],{"id":1906},"_6-mockmvc把接口测起来","6 MockMvc：把接口测起来",[13,1909,1910],{},"接口的价值在于「行为契约」，所以最值得写的就是这种端到端测试——真实上下文、真实拦截器、\n真实数据库：",[273,1912,1914],{"className":275,"code":1913,"language":277,"meta":278,"style":278},"package com.example.library.web;\n\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.http.MediaType;\nimport org.springframework.test.web.servlet.MockMvc;\n\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\n\u002F** 端到端：真实上下文 + 真实拦截器 + 真实数据库 *\u002F\n@SpringBootTest\n@AutoConfigureMockMvc\nclass BookControllerTest {\n\n    @Autowired\n    private MockMvc mockMvc;\n\n    @Test\n    @DisplayName(\"GET \u002Fapi\u002Fbooks 返回分页 JSON，并且不被登录拦截器拦下\")\n    void listsBooks() throws Exception {\n        mockMvc.perform(get(\"\u002Fapi\u002Fbooks\").param(\"size\", \"3\"))\n                .andExpect(status().isOk())\n                .andExpect(jsonPath(\"$.content\").isArray())\n                .andExpect(jsonPath(\"$.totalElements\").isNumber());\n    }\n\n    @Test\n    @DisplayName(\"POST \u002Fapi\u002Fbooks 返回 201 与新建的图书\")\n    void createsBook() throws Exception {\n        mockMvc.perform(post(\"\u002Fapi\u002Fbooks\")\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(\"\"\"\n                                 {\"title\":\"重构\",\"author\":\"Martin Fowler\"}\n                                 \"\"\"))\n                .andExpect(status().isCreated())\n                .andExpect(jsonPath(\"$.title\").value(\"重构\"))\n                .andExpect(jsonPath(\"$.id\").isNumber());\n    }\n\n    @Test\n    @DisplayName(\"查询不存在的图书返回 404 与 ProblemDetail\")\n    void returnsProblemDetailForMissingBook() throws Exception {\n        mockMvc.perform(get(\"\u002Fapi\u002Fbooks\u002F999999\"))\n                .andExpect(status().isNotFound())\n                .andExpect(jsonPath(\"$.title\").value(\"资源不存在\"))\n                .andExpect(jsonPath(\"$.status\").value(404));\n    }\n}\n",[45,1915,1916,1922,1926,1933,1940,1947,1954,1961,1968,1975,1979,1988,1997,2006,2015,2019,2024,2031,2038,2048,2052,2059,2066,2070,2077,2091,2107,2142,2162,2185,2207,2211,2215,2221,2234,2247,2264,2275,2287,2292,2299,2316,2343,2364,2368,2372,2378,2391,2404,2421,2438,2462,2488,2492],{"__ignoreMap":278},[282,1917,1918,1920],{"class":284,"line":285},[282,1919,289],{"class":288},[282,1921,293],{"class":292},[282,1923,1924],{"class":284,"line":296},[282,1925,300],{"emptyLinePlaceholder":299},[282,1927,1928,1930],{"class":284,"line":303},[282,1929,306],{"class":288},[282,1931,1932],{"class":292}," org.junit.jupiter.api.DisplayName;\n",[282,1934,1935,1937],{"class":284,"line":312},[282,1936,306],{"class":288},[282,1938,1939],{"class":292}," org.junit.jupiter.api.Test;\n",[282,1941,1942,1944],{"class":284,"line":320},[282,1943,306],{"class":288},[282,1945,1946],{"class":292}," org.springframework.beans.factory.annotation.Autowired;\n",[282,1948,1949,1951],{"class":284,"line":328},[282,1950,306],{"class":288},[282,1952,1953],{"class":292}," org.springframework.boot.test.context.SpringBootTest;\n",[282,1955,1956,1958],{"class":284,"line":336},[282,1957,306],{"class":288},[282,1959,1960],{"class":292}," org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\n",[282,1962,1963,1965],{"class":284,"line":344},[282,1964,306],{"class":288},[282,1966,1967],{"class":292}," org.springframework.http.MediaType;\n",[282,1969,1970,1972],{"class":284,"line":352},[282,1971,306],{"class":288},[282,1973,1974],{"class":292}," org.springframework.test.web.servlet.MockMvc;\n",[282,1976,1977],{"class":284,"line":360},[282,1978,300],{"emptyLinePlaceholder":299},[282,1980,1981,1983,1985],{"class":284,"line":368},[282,1982,306],{"class":288},[282,1984,1147],{"class":288},[282,1986,1987],{"class":292}," org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;\n",[282,1989,1990,1992,1994],{"class":284,"line":376},[282,1991,306],{"class":288},[282,1993,1147],{"class":288},[282,1995,1996],{"class":292}," org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\n",[282,1998,1999,2001,2003],{"class":284,"line":384},[282,2000,306],{"class":288},[282,2002,1147],{"class":288},[282,2004,2005],{"class":292}," org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\n",[282,2007,2008,2010,2012],{"class":284,"line":392},[282,2009,306],{"class":288},[282,2011,1147],{"class":288},[282,2013,2014],{"class":292}," org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n",[282,2016,2017],{"class":284,"line":400},[282,2018,300],{"emptyLinePlaceholder":299},[282,2020,2021],{"class":284,"line":408},[282,2022,2023],{"class":1123},"\u002F** 端到端：真实上下文 + 真实拦截器 + 真实数据库 *\u002F\n",[282,2025,2026,2028],{"class":284,"line":416},[282,2027,461],{"class":292},[282,2029,2030],{"class":288},"SpringBootTest\n",[282,2032,2033,2035],{"class":284,"line":424},[282,2034,461],{"class":292},[282,2036,2037],{"class":288},"AutoConfigureMockMvc\n",[282,2039,2040,2043,2046],{"class":284,"line":432},[282,2041,2042],{"class":288},"class",[282,2044,2045],{"class":494}," BookControllerTest",[282,2047,498],{"class":292},[282,2049,2050],{"class":284,"line":440},[282,2051,300],{"emptyLinePlaceholder":299},[282,2053,2054,2056],{"class":284,"line":445},[282,2055,571],{"class":292},[282,2057,2058],{"class":288},"Autowired\n",[282,2060,2061,2063],{"class":284,"line":453},[282,2062,509],{"class":288},[282,2064,2065],{"class":292}," MockMvc mockMvc;\n",[282,2067,2068],{"class":284,"line":458},[282,2069,300],{"emptyLinePlaceholder":299},[282,2071,2072,2074],{"class":284,"line":467},[282,2073,571],{"class":292},[282,2075,2076],{"class":288},"Test\n",[282,2078,2079,2081,2084,2086,2089],{"class":284,"line":485},[282,2080,571],{"class":292},[282,2082,2083],{"class":288},"DisplayName",[282,2085,475],{"class":292},[282,2087,2088],{"class":478},"\"GET \u002Fapi\u002Fbooks 返回分页 JSON，并且不被登录拦截器拦下\"",[282,2090,482],{"class":292},[282,2092,2093,2096,2099,2101,2104],{"class":284,"line":501},[282,2094,2095],{"class":288},"    void",[282,2097,2098],{"class":494}," listsBooks",[282,2100,1176],{"class":292},[282,2102,2103],{"class":288},"throws",[282,2105,2106],{"class":292}," Exception {\n",[282,2108,2109,2112,2115,2117,2119,2121,2123,2126,2129,2131,2134,2136,2139],{"class":284,"line":506},[282,2110,2111],{"class":292},"        mockMvc.",[282,2113,2114],{"class":494},"perform",[282,2116,475],{"class":292},[282,2118,690],{"class":494},[282,2120,475],{"class":292},[282,2122,479],{"class":478},[282,2124,2125],{"class":292},").",[282,2127,2128],{"class":494},"param",[282,2130,475],{"class":292},[282,2132,2133],{"class":478},"\"size\"",[282,2135,611],{"class":292},[282,2137,2138],{"class":478},"\"3\"",[282,2140,2141],{"class":292},"))\n",[282,2143,2144,2147,2150,2152,2154,2156,2159],{"class":284,"line":518},[282,2145,2146],{"class":292},"                .",[282,2148,2149],{"class":494},"andExpect",[282,2151,475],{"class":292},[282,2153,938],{"class":494},[282,2155,1197],{"class":292},[282,2157,2158],{"class":494},"isOk",[282,2160,2161],{"class":292},"())\n",[282,2163,2164,2166,2168,2170,2173,2175,2178,2180,2183],{"class":284,"line":523},[282,2165,2146],{"class":292},[282,2167,2149],{"class":494},[282,2169,475],{"class":292},[282,2171,2172],{"class":494},"jsonPath",[282,2174,475],{"class":292},[282,2176,2177],{"class":478},"\"$.content\"",[282,2179,2125],{"class":292},[282,2181,2182],{"class":494},"isArray",[282,2184,2161],{"class":292},[282,2186,2187,2189,2191,2193,2195,2197,2200,2202,2205],{"class":284,"line":541},[282,2188,2146],{"class":292},[282,2190,2149],{"class":494},[282,2192,475],{"class":292},[282,2194,2172],{"class":494},[282,2196,475],{"class":292},[282,2198,2199],{"class":478},"\"$.totalElements\"",[282,2201,2125],{"class":292},[282,2203,2204],{"class":494},"isNumber",[282,2206,856],{"class":292},[282,2208,2209],{"class":284,"line":557},[282,2210,560],{"class":292},[282,2212,2213],{"class":284,"line":563},[282,2214,300],{"emptyLinePlaceholder":299},[282,2216,2217,2219],{"class":284,"line":568},[282,2218,571],{"class":292},[282,2220,2076],{"class":288},[282,2222,2223,2225,2227,2229,2232],{"class":284,"line":577},[282,2224,571],{"class":292},[282,2226,2083],{"class":288},[282,2228,475],{"class":292},[282,2230,2231],{"class":478},"\"POST \u002Fapi\u002Fbooks 返回 201 与新建的图书\"",[282,2233,482],{"class":292},[282,2235,2236,2238,2241,2243,2245],{"class":284,"line":631},[282,2237,2095],{"class":288},[282,2239,2240],{"class":494}," createsBook",[282,2242,1176],{"class":292},[282,2244,2103],{"class":288},[282,2246,2106],{"class":292},[282,2248,2249,2251,2253,2255,2258,2260,2262],{"class":284,"line":642},[282,2250,2111],{"class":292},[282,2252,2114],{"class":494},[282,2254,475],{"class":292},[282,2256,2257],{"class":494},"post",[282,2259,475],{"class":292},[282,2261,479],{"class":478},[282,2263,482],{"class":292},[282,2265,2266,2269,2272],{"class":284,"line":657},[282,2267,2268],{"class":292},"                        .",[282,2270,2271],{"class":494},"contentType",[282,2273,2274],{"class":292},"(MediaType.APPLICATION_JSON)\n",[282,2276,2277,2279,2282,2284],{"class":284,"line":662},[282,2278,2268],{"class":292},[282,2280,2281],{"class":494},"content",[282,2283,475],{"class":292},[282,2285,2286],{"class":478},"\"\"\"\n",[282,2288,2289],{"class":284,"line":667},[282,2290,2291],{"class":478},"                                 {\"title\":\"重构\",\"author\":\"Martin Fowler\"}\n",[282,2293,2294,2297],{"class":284,"line":682},[282,2295,2296],{"class":478},"                                 \"\"\"",[282,2298,2141],{"class":292},[282,2300,2301,2303,2305,2307,2309,2311,2314],{"class":284,"line":706},[282,2302,2146],{"class":292},[282,2304,2149],{"class":494},[282,2306,475],{"class":292},[282,2308,938],{"class":494},[282,2310,1197],{"class":292},[282,2312,2313],{"class":494},"isCreated",[282,2315,2161],{"class":292},[282,2317,2318,2320,2322,2324,2326,2328,2331,2333,2336,2338,2341],{"class":284,"line":725},[282,2319,2146],{"class":292},[282,2321,2149],{"class":494},[282,2323,475],{"class":292},[282,2325,2172],{"class":494},[282,2327,475],{"class":292},[282,2329,2330],{"class":478},"\"$.title\"",[282,2332,2125],{"class":292},[282,2334,2335],{"class":494},"value",[282,2337,475],{"class":292},[282,2339,2340],{"class":478},"\"重构\"",[282,2342,2141],{"class":292},[282,2344,2345,2347,2349,2351,2353,2355,2358,2360,2362],{"class":284,"line":730},[282,2346,2146],{"class":292},[282,2348,2149],{"class":494},[282,2350,475],{"class":292},[282,2352,2172],{"class":494},[282,2354,475],{"class":292},[282,2356,2357],{"class":478},"\"$.id\"",[282,2359,2125],{"class":292},[282,2361,2204],{"class":494},[282,2363,856],{"class":292},[282,2365,2366],{"class":284,"line":735},[282,2367,560],{"class":292},[282,2369,2370],{"class":284,"line":749},[282,2371,300],{"emptyLinePlaceholder":299},[282,2373,2374,2376],{"class":284,"line":767},[282,2375,571],{"class":292},[282,2377,2076],{"class":288},[282,2379,2380,2382,2384,2386,2389],{"class":284,"line":780},[282,2381,571],{"class":292},[282,2383,2083],{"class":288},[282,2385,475],{"class":292},[282,2387,2388],{"class":478},"\"查询不存在的图书返回 404 与 ProblemDetail\"",[282,2390,482],{"class":292},[282,2392,2393,2395,2398,2400,2402],{"class":284,"line":785},[282,2394,2095],{"class":288},[282,2396,2397],{"class":494}," returnsProblemDetailForMissingBook",[282,2399,1176],{"class":292},[282,2401,2103],{"class":288},[282,2403,2106],{"class":292},[282,2405,2406,2408,2410,2412,2414,2416,2419],{"class":284,"line":790},[282,2407,2111],{"class":292},[282,2409,2114],{"class":494},[282,2411,475],{"class":292},[282,2413,690],{"class":494},[282,2415,475],{"class":292},[282,2417,2418],{"class":478},"\"\u002Fapi\u002Fbooks\u002F999999\"",[282,2420,2141],{"class":292},[282,2422,2423,2425,2427,2429,2431,2433,2436],{"class":284,"line":798},[282,2424,2146],{"class":292},[282,2426,2149],{"class":494},[282,2428,475],{"class":292},[282,2430,938],{"class":494},[282,2432,1197],{"class":292},[282,2434,2435],{"class":494},"isNotFound",[282,2437,2161],{"class":292},[282,2439,2440,2442,2444,2446,2448,2450,2452,2454,2456,2458,2460],{"class":284,"line":809},[282,2441,2146],{"class":292},[282,2443,2149],{"class":494},[282,2445,475],{"class":292},[282,2447,2172],{"class":494},[282,2449,475],{"class":292},[282,2451,2330],{"class":478},[282,2453,2125],{"class":292},[282,2455,2335],{"class":494},[282,2457,475],{"class":292},[282,2459,1663],{"class":478},[282,2461,2141],{"class":292},[282,2463,2464,2466,2468,2470,2472,2474,2477,2479,2481,2483,2485],{"class":284,"line":832},[282,2465,2146],{"class":292},[282,2467,2149],{"class":494},[282,2469,475],{"class":292},[282,2471,2172],{"class":494},[282,2473,475],{"class":292},[282,2475,2476],{"class":478},"\"$.status\"",[282,2478,2125],{"class":292},[282,2480,2335],{"class":494},[282,2482,475],{"class":292},[282,2484,1868],{"class":544},[282,2486,2487],{"class":292},"));\n",[282,2489,2490],{"class":284,"line":859},[282,2491,560],{"class":292},[282,2493,2494],{"class":284,"line":871},[282,2495,1013],{"class":292},[13,2497,2498,2499,2502,2503,98,2506,2509,2510,2513],{},"三条测试分别盯住三件事：",[40,2500,2501],{},"返回结构","（分页字段）、",[40,2504,2505],{},"写操作的状态码与响应体",[40,2507,2508],{},"错误契约","。\n它们同时在验证第 09 篇那个登录拦截器的 ",[45,2511,2512],{},"excludePathPatterns(\"\u002Fapi\u002F**\")"," 有没有配对——第一个\n测试没带任何会话，能返回 200 就说明接口没被页面那套拦截挡住。",[13,2515,2516,2519,2520,2523,2524,1297],{},[45,2517,2518],{},"@AutoConfigureMockMvc"," 在 Boot 4 里也从 ",[45,2521,2522],{},"org.springframework.boot.test.autoconfigure.web.servlet","\n搬到了 ",[45,2525,2526],{},"org.springframework.boot.webmvc.test.autoconfigure",[17,2528,2530],{"id":2529},"_7-切片测试快但有两个坑","7 切片测试：快，但有两个坑",[13,2532,2533,2534,2537,2538,1037],{},"只测 MVC 层（不启动数据库、不启动整个应用）用 ",[45,2535,2536],{},"@WebMvcTest","，依赖换成\n",[45,2539,2540],{},"spring-boot-starter-webmvc-test",[273,2542,2544],{"className":275,"code":2543,"language":277,"meta":278,"style":278},"@WebMvcTest(BookController.class)\nclass BookControllerSliceTest {\n\n    @Autowired\n    private MockMvc mockMvc;\n\n    @MockitoBean\n    private BookService bookService;\n\n    @MockitoBean\n    private LoginInterceptor loginInterceptor;\n\n    @MockitoBean\n    private AdminInterceptor adminInterceptor;\n\n    @MockitoBean\n    private TimingInterceptor timingInterceptor;\n\n    @BeforeEach\n    void allowAllRequestsThrough() throws Exception {\n        \u002F\u002F Mock 出来的拦截器 preHandle 默认返回 false，会让所有请求中断，必须显式放行\n        given(loginInterceptor.preHandle(any(), any(), any())).willReturn(true);\n        given(adminInterceptor.preHandle(any(), any(), any())).willReturn(true);\n        given(timingInterceptor.preHandle(any(), any(), any())).willReturn(true);\n    }\n\n    @Test\n    @DisplayName(\"切片测试：返回图书 JSON\")\n    void returnsBookJson() throws Exception {\n        Book book = new Book(\"重构\", \"Martin Fowler\");\n        given(bookService.findById(1L)).willReturn(book);\n        given(bookService.toResponse(book)).willReturn(\n                new BookResponse(1L, \"重构\", \"Martin Fowler\", BookStatus.AVAILABLE, \"技术\"));\n\n        mockMvc.perform(get(\"\u002Fapi\u002Fbooks\u002F1\"))\n                .andExpect(status().isOk())\n                .andExpect(jsonPath(\"$.title\").value(\"重构\"))\n                .andExpect(jsonPath(\"$.categoryName\").value(\"技术\"));\n    }\n}\n",[45,2545,2546,2556,2565,2569,2575,2581,2585,2592,2598,2602,2608,2615,2619,2625,2632,2636,2642,2649,2653,2660,2673,2678,2716,2747,2778,2782,2786,2792,2805,2818,2840,2860,2876,2903,2907,2924,2940,2964,2989,2993],{"__ignoreMap":278},[282,2547,2548,2550,2553],{"class":284,"line":285},[282,2549,461],{"class":292},[282,2551,2552],{"class":288},"WebMvcTest",[282,2554,2555],{"class":292},"(BookController.class)\n",[282,2557,2558,2560,2563],{"class":284,"line":296},[282,2559,2042],{"class":288},[282,2561,2562],{"class":494}," BookControllerSliceTest",[282,2564,498],{"class":292},[282,2566,2567],{"class":284,"line":303},[282,2568,300],{"emptyLinePlaceholder":299},[282,2570,2571,2573],{"class":284,"line":312},[282,2572,571],{"class":292},[282,2574,2058],{"class":288},[282,2576,2577,2579],{"class":284,"line":320},[282,2578,509],{"class":288},[282,2580,2065],{"class":292},[282,2582,2583],{"class":284,"line":328},[282,2584,300],{"emptyLinePlaceholder":299},[282,2586,2587,2589],{"class":284,"line":336},[282,2588,571],{"class":292},[282,2590,2591],{"class":288},"MockitoBean\n",[282,2593,2594,2596],{"class":284,"line":344},[282,2595,509],{"class":288},[282,2597,515],{"class":292},[282,2599,2600],{"class":284,"line":352},[282,2601,300],{"emptyLinePlaceholder":299},[282,2603,2604,2606],{"class":284,"line":360},[282,2605,571],{"class":292},[282,2607,2591],{"class":288},[282,2609,2610,2612],{"class":284,"line":368},[282,2611,509],{"class":288},[282,2613,2614],{"class":292}," LoginInterceptor loginInterceptor;\n",[282,2616,2617],{"class":284,"line":376},[282,2618,300],{"emptyLinePlaceholder":299},[282,2620,2621,2623],{"class":284,"line":384},[282,2622,571],{"class":292},[282,2624,2591],{"class":288},[282,2626,2627,2629],{"class":284,"line":392},[282,2628,509],{"class":288},[282,2630,2631],{"class":292}," AdminInterceptor adminInterceptor;\n",[282,2633,2634],{"class":284,"line":400},[282,2635,300],{"emptyLinePlaceholder":299},[282,2637,2638,2640],{"class":284,"line":408},[282,2639,571],{"class":292},[282,2641,2591],{"class":288},[282,2643,2644,2646],{"class":284,"line":416},[282,2645,509],{"class":288},[282,2647,2648],{"class":292}," TimingInterceptor timingInterceptor;\n",[282,2650,2651],{"class":284,"line":424},[282,2652,300],{"emptyLinePlaceholder":299},[282,2654,2655,2657],{"class":284,"line":432},[282,2656,571],{"class":292},[282,2658,2659],{"class":288},"BeforeEach\n",[282,2661,2662,2664,2667,2669,2671],{"class":284,"line":440},[282,2663,2095],{"class":288},[282,2665,2666],{"class":494}," allowAllRequestsThrough",[282,2668,1176],{"class":292},[282,2670,2103],{"class":288},[282,2672,2106],{"class":292},[282,2674,2675],{"class":284,"line":445},[282,2676,2677],{"class":1123},"        \u002F\u002F Mock 出来的拦截器 preHandle 默认返回 false，会让所有请求中断，必须显式放行\n",[282,2679,2680,2683,2686,2689,2691,2694,2697,2699,2701,2703,2706,2709,2711,2714],{"class":284,"line":453},[282,2681,2682],{"class":494},"        given",[282,2684,2685],{"class":292},"(loginInterceptor.",[282,2687,2688],{"class":494},"preHandle",[282,2690,475],{"class":292},[282,2692,2693],{"class":494},"any",[282,2695,2696],{"class":292},"(), ",[282,2698,2693],{"class":494},[282,2700,2696],{"class":292},[282,2702,2693],{"class":494},[282,2704,2705],{"class":292},"())).",[282,2707,2708],{"class":494},"willReturn",[282,2710,475],{"class":292},[282,2712,2713],{"class":544},"true",[282,2715,1666],{"class":292},[282,2717,2718,2720,2723,2725,2727,2729,2731,2733,2735,2737,2739,2741,2743,2745],{"class":284,"line":458},[282,2719,2682],{"class":494},[282,2721,2722],{"class":292},"(adminInterceptor.",[282,2724,2688],{"class":494},[282,2726,475],{"class":292},[282,2728,2693],{"class":494},[282,2730,2696],{"class":292},[282,2732,2693],{"class":494},[282,2734,2696],{"class":292},[282,2736,2693],{"class":494},[282,2738,2705],{"class":292},[282,2740,2708],{"class":494},[282,2742,475],{"class":292},[282,2744,2713],{"class":544},[282,2746,1666],{"class":292},[282,2748,2749,2751,2754,2756,2758,2760,2762,2764,2766,2768,2770,2772,2774,2776],{"class":284,"line":467},[282,2750,2682],{"class":494},[282,2752,2753],{"class":292},"(timingInterceptor.",[282,2755,2688],{"class":494},[282,2757,475],{"class":292},[282,2759,2693],{"class":494},[282,2761,2696],{"class":292},[282,2763,2693],{"class":494},[282,2765,2696],{"class":292},[282,2767,2693],{"class":494},[282,2769,2705],{"class":292},[282,2771,2708],{"class":494},[282,2773,475],{"class":292},[282,2775,2713],{"class":544},[282,2777,1666],{"class":292},[282,2779,2780],{"class":284,"line":485},[282,2781,560],{"class":292},[282,2783,2784],{"class":284,"line":501},[282,2785,300],{"emptyLinePlaceholder":299},[282,2787,2788,2790],{"class":284,"line":506},[282,2789,571],{"class":292},[282,2791,2076],{"class":288},[282,2793,2794,2796,2798,2800,2803],{"class":284,"line":518},[282,2795,571],{"class":292},[282,2797,2083],{"class":288},[282,2799,475],{"class":292},[282,2801,2802],{"class":478},"\"切片测试：返回图书 JSON\"",[282,2804,482],{"class":292},[282,2806,2807,2809,2812,2814,2816],{"class":284,"line":523},[282,2808,2095],{"class":288},[282,2810,2811],{"class":494}," returnsBookJson",[282,2813,1176],{"class":292},[282,2815,2103],{"class":288},[282,2817,2106],{"class":292},[282,2819,2820,2822,2824,2826,2829,2831,2833,2835,2838],{"class":284,"line":541},[282,2821,835],{"class":292},[282,2823,551],{"class":288},[282,2825,1209],{"class":288},[282,2827,2828],{"class":494}," Book",[282,2830,475],{"class":292},[282,2832,2340],{"class":478},[282,2834,611],{"class":292},[282,2836,2837],{"class":478},"\"Martin Fowler\"",[282,2839,1666],{"class":292},[282,2841,2842,2844,2846,2848,2850,2853,2856,2858],{"class":284,"line":557},[282,2843,2682],{"class":494},[282,2845,716],{"class":292},[282,2847,719],{"class":494},[282,2849,475],{"class":292},[282,2851,2852],{"class":544},"1L",[282,2854,2855],{"class":292},")).",[282,2857,2708],{"class":494},[282,2859,868],{"class":292},[282,2861,2862,2864,2866,2868,2871,2873],{"class":284,"line":563},[282,2863,2682],{"class":494},[282,2865,716],{"class":292},[282,2867,713],{"class":494},[282,2869,2870],{"class":292},"(book)).",[282,2872,2708],{"class":494},[282,2874,2875],{"class":292},"(\n",[282,2877,2878,2881,2883,2885,2887,2889,2891,2893,2895,2898,2901],{"class":284,"line":568},[282,2879,2880],{"class":288},"                new",[282,2882,1133],{"class":494},[282,2884,475],{"class":292},[282,2886,2852],{"class":544},[282,2888,611],{"class":292},[282,2890,2340],{"class":478},[282,2892,611],{"class":292},[282,2894,2837],{"class":478},[282,2896,2897],{"class":292},", BookStatus.AVAILABLE, ",[282,2899,2900],{"class":478},"\"技术\"",[282,2902,2487],{"class":292},[282,2904,2905],{"class":284,"line":577},[282,2906,300],{"emptyLinePlaceholder":299},[282,2908,2909,2911,2913,2915,2917,2919,2922],{"class":284,"line":631},[282,2910,2111],{"class":292},[282,2912,2114],{"class":494},[282,2914,475],{"class":292},[282,2916,690],{"class":494},[282,2918,475],{"class":292},[282,2920,2921],{"class":478},"\"\u002Fapi\u002Fbooks\u002F1\"",[282,2923,2141],{"class":292},[282,2925,2926,2928,2930,2932,2934,2936,2938],{"class":284,"line":642},[282,2927,2146],{"class":292},[282,2929,2149],{"class":494},[282,2931,475],{"class":292},[282,2933,938],{"class":494},[282,2935,1197],{"class":292},[282,2937,2158],{"class":494},[282,2939,2161],{"class":292},[282,2941,2942,2944,2946,2948,2950,2952,2954,2956,2958,2960,2962],{"class":284,"line":657},[282,2943,2146],{"class":292},[282,2945,2149],{"class":494},[282,2947,475],{"class":292},[282,2949,2172],{"class":494},[282,2951,475],{"class":292},[282,2953,2330],{"class":478},[282,2955,2125],{"class":292},[282,2957,2335],{"class":494},[282,2959,475],{"class":292},[282,2961,2340],{"class":478},[282,2963,2141],{"class":292},[282,2965,2966,2968,2970,2972,2974,2976,2979,2981,2983,2985,2987],{"class":284,"line":662},[282,2967,2146],{"class":292},[282,2969,2149],{"class":494},[282,2971,475],{"class":292},[282,2973,2172],{"class":494},[282,2975,475],{"class":292},[282,2977,2978],{"class":478},"\"$.categoryName\"",[282,2980,2125],{"class":292},[282,2982,2335],{"class":494},[282,2984,475],{"class":292},[282,2986,2900],{"class":478},[282,2988,2487],{"class":292},[282,2990,2991],{"class":284,"line":667},[282,2992,560],{"class":292},[282,2994,2995],{"class":284,"line":682},[282,2996,1013],{"class":292},[13,2998,2999],{},"两个坑都在上面这段代码里留了痕迹：",[3001,3002,3003,3030],"ol",{},[28,3004,3005,3011,3012,3015,3016,3018,3019,3022,3023,3026,3027,3029],{},[40,3006,3007,3010],{},[45,3008,3009],{},"WebConfig"," 会被一起加载","。它也是 ",[45,3013,3014],{},"WebMvcConfigurer","，属于 MVC 层的一部分，所以\n",[45,3017,2536],{}," 会把它装上；而它构造器依赖三个拦截器 Bean（",[45,3020,3021],{},"@Component","，切片测试",[40,3024,3025],{},"不","\n扫描），于是上下文启动失败。解法就是给这三个拦截器提供替身（或者把 ",[45,3028,3009],{}," 从切片里\n排除掉）；",[28,3031,3032,3041,3042,3045,3046,3048,3049,3052,3053,1297],{},[40,3033,3034,3035,3037,3038],{},"替身的 ",[45,3036,2688],{}," 默认返回 ",[45,3039,3040],{},"false","。Mockito 对返回 ",[45,3043,3044],{},"boolean"," 的方法默认给 ",[45,3047,3040],{},"，\n于是每个请求都被判成「未登录」——测试表现是 403\u002F302 而不是断言失败，很容易看错方向。\n所以 ",[45,3050,3051],{},"@BeforeEach"," 里必须显式 ",[45,3054,3055],{},"willReturn(true)",[13,3057,3058,3061,3062,3065,3066,3069,3070,3073,3074,1297],{},[45,3059,3060],{},"@MockitoBean"," 在 Boot 4 里位于 ",[45,3063,3064],{},"org.springframework.test.context.bean.override.mockito.MockitoBean","，\n它取代了已被移除的 ",[45,3067,3068],{},"@MockBean","。另外别忘了第 01 篇那个环境坑：JDK 17 下跑任何 Mockito 测试\n",[45,3071,3072],{},"surefire"," 都需要 ",[45,3075,3076],{},"-Djdk.attach.allowAttachSelf=true",[17,3078,3080],{"id":3079},"_8-容易踩的坑","8 容易踩的坑",[69,3082,3083,3096],{},[72,3084,3085],{},[75,3086,3087,3090,3093],{},[78,3088,3089],{},"现象",[78,3091,3092],{},"原因",[78,3094,3095],{},"处理",[85,3097,3098,3119,3137,3155,3166,3184,3197,3216,3237,3255],{},[75,3099,3100,3106,3109],{},[90,3101,3102,3103],{},"启动直接失败：",[45,3104,3105],{},"Ambiguous mapping",[90,3107,3108],{},"两个控制器映射到了同一个「方法 + 路径」",[90,3110,3111,3112,3115,3116],{},"改路径。示例工程里 ",[45,3113,3114],{},"\u002Fbooks\u002Fnew"," 同时被页面控制器和多步表单控制器占用，把后者挪到了 ",[45,3117,3118],{},"\u002Fbooks\u002Fwizard",[75,3120,3121,3124,3131],{},[90,3122,3123],{},"GET 请求返回 405",[90,3125,3126,3127,3130],{},"路径对了但请求方法不在映射里（只有 ",[45,3128,3129],{},"@PostMapping","）",[90,3132,3133,3134,3136],{},"补上对应的映射，或用 ",[45,3135,1035],{}," 放开方法",[75,3138,3139,3142,3149],{},[90,3140,3141],{},"客户端收到的是 HTML 错误页而不是 JSON",[90,3143,3144,3145,3148],{},"异常被页面型的 ",[45,3146,3147],{},"@ControllerAdvice"," 接管了",[90,3150,3151,3152,3154],{},"两个 advice 都用 ",[45,3153,1581],{}," 限定各自的范围",[75,3156,3157,3160,3163],{},[90,3158,3159],{},"接口返回里出现一堆意外的字段（甚至报延迟加载异常）",[90,3161,3162],{},"直接把实体当响应体返回",[90,3164,3165],{},"用 DTO \u002F 投影返回",[75,3167,3168,3175,3181],{},[90,3169,3170,3171,98,3173,3130],{},"客户端能改到不该改的字段（比如 ",[45,3172,938],{},[45,3174,701],{},[90,3176,3177,3178],{},"用实体接收 ",[45,3179,3180],{},"@RequestBody",[90,3182,3183],{},"用专门的请求 record，只放允许的字段",[75,3185,3186,3191,3194],{},[90,3187,3188,3190],{},[45,3189,189],{}," 重复调用产生重复数据",[90,3192,3193],{},"写操作本来就不幂等",[90,3195,3196],{},"用业务唯一键 + 唯一约束兜底；或改设计（第 12 篇）",[75,3198,3199,3206,3209],{},[90,3200,3201,3202,3205],{},"分页接口被 ",[45,3203,3204],{},"?sort="," 拖慢",[90,3207,3208],{},"排序字段客户端可控",[90,3210,3211,3212,3215],{},"限制 ",[45,3213,3214],{},"spring.data.web.pageable.max-page-size","，并做字段白名单",[75,3217,3218,3221,3231],{},[90,3219,3220],{},"切片测试启动就失败，报找不到拦截器 Bean",[90,3222,3223,3225,3226,3228,3229],{},[45,3224,2536],{}," 会加载 ",[45,3227,3014],{},"，却不会扫描 ",[45,3230,3021],{},[90,3232,3233,3234,3236],{},"给拦截器提供 ",[45,3235,3060],{}," 替身",[75,3238,3239,3242,3248],{},[90,3240,3241],{},"切片测试里每个请求都被判未登录",[90,3243,3034,3244,3037,3246],{},[45,3245,2688],{},[45,3247,3040],{},[90,3249,3250,3252,3253],{},[45,3251,3051],{}," 里显式 ",[45,3254,3055],{},[75,3256,3257,3263,3266],{},[90,3258,3259,3260],{},"所有 Mockito 测试启动时报 ",[45,3261,3262],{},"MockitoInitializationException",[90,3264,3265],{},"JDK 17 默认禁止 agent 自附加",[90,3267,3268,3269,3271],{},"surefire 加 ",[45,3270,3076],{},"（第 01 篇）",[17,3273,3275],{"id":3274},"_9-小结","9 小结",[25,3277,3278,3286,3293,3300,3313,3327],{},[28,3279,3280,3285],{},[40,3281,3282,3283],{},"Service 不动，加一层薄薄的 ",[45,3284,258],{}," 就能同时服务页面与客户端；",[28,3287,3288,3289,3292],{},"REST 的四条约定：",[40,3290,3291],{},"名词资源、方法表意、状态码表意、无状态","；记住方法与「安全\u002F幂等」的对应；",[28,3294,3295,3296,3299],{},"请求体与响应体都用 ",[40,3297,3298],{},"DTO","（record 最省事），不要直接暴露实体；",[28,3301,3302,3305,3306,1080,3309,3312],{},[40,3303,3304],{},"状态码","用 ",[45,3307,3308],{},"@ResponseStatus",[45,3310,3311],{},"ResponseEntity"," \u002F 异常映射三种方式表达；",[28,3314,3315,3316,3323,3324,3326],{},"错误统一用 ",[40,3317,3318,263,3320],{},[45,3319,1503],{},[45,3321,3322],{},"@RestControllerAdvice(assignableTypes = ...)","，并用\n",[45,3325,1581],{}," 与页面型的 advice 隔开；",[28,3328,3329,3330,3337,3338,3340,3341,3343,3344,3346],{},"测试优先写 ",[40,3331,3332,263,3335],{},[45,3333,3334],{},"@SpringBootTest",[45,3336,2518],{}," 的端到端断言；\n",[45,3339,2536],{}," 切片更快，但要处理 ",[45,3342,3014],{}," 依赖与替身 ",[45,3345,2688],{}," 这两个坑。",[13,3348,3349],{},"接口能跑通了，但「能用的接口」和「好用的接口」之间还有距离：URL 怎么设计、怎么演进版本、\n怎么让更新可以安全重试、跨域怎么办。下一篇集中处理这些。",[3351,3352,3353],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":278,"searchDepth":303,"depth":303,"links":3355},[3356,3357,3358,3359,3360,3361,3362,3363,3364,3365],{"id":19,"depth":296,"text":20},{"id":63,"depth":296,"text":64},{"id":143,"depth":296,"text":144},{"id":251,"depth":296,"text":252},{"id":1300,"depth":296,"text":1301},{"id":1496,"depth":296,"text":1497},{"id":1906,"depth":296,"text":1907},{"id":2529,"depth":296,"text":2530},{"id":3079,"depth":296,"text":3080},{"id":3274,"depth":296,"text":3275},"学习",null,"2026-10-15","同一套 Service 怎么同时服务页面和客户端：@RestController 的路由与方法语义、状态码怎么给、DTO 怎么定，以及 MockMvc 端到端和切片测试的两种写法与各自的坑。","md",false,{},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-11",{"title":5,"description":3369},"Spring 学习笔记","posts\u002Fjavaspring\u002Fspring-notes-11",[3378,3379,3380],"Spring","REST","学习笔记","kw2OeHmwhgnhr-1EPdNRQqz22ua1xra4ZR_1BBXPyNk",[3383,3389,3394,3399,3404,3409,3414,3419,3424,3429,3434,3439,3444,3449,3454,3459,3464,3469,3474,3479,3484,3489,3494,3499,3504,3509,3514,3519,3524,3528,3532,3536,3540,3544,3548,3552,3556,3560,3564,3565,3569,3575,3579,3585,3589,3593,3597,3601,3605,3609,3613,3618,3622,3626,3631,3636,3642,3646,3651,3655],{"path":3384,"title":3385,"description":3386,"date":3387,"series":3388,"seriesOrder":518},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-28","软件分析与设计笔记 28：draw.io 作图手册：五张图的符号位置、落笔顺序与自检","不模拟软件界面，只讲清三件事：每个 UML 符号在 draw.io 里搜什么词、一张图该按什么顺序落笔、画完拿什么标准自查。覆盖用例图、活动图、顺序图、类图、界面草图五种图纸，末尾附「最容易画反的十二笔」总检表。","2026-11-19","软件分析与设计笔记",{"path":3390,"title":3391,"description":3392,"date":3393,"series":3388,"seriesOrder":506},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-27","软件分析与设计笔记 27：Paper 1 应试：从读案例到交卷的一次完整演算","拿一份完整的案例，从头到尾走一遍：怎么读案例列角色、怎么在四个小问之间分配时间、每一张图从哪一句话推出来、两张表怎么填、交卷前拿什么清单自查。附 C25 真题与样卷的题型分值对照。","2026-11-18",{"path":3395,"title":3396,"description":3397,"date":3398,"series":3388,"seriesOrder":501},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-26","软件分析与设计笔记 26：总结：把整条链路串起来","系列收尾：从需求到代码的完整链路回顾、每一环的产出与验收方式、六条贯穿全系列的原则，以及一份可以直接拿去做项目自查的清单。","2026-11-17",{"path":3400,"title":3401,"description":3402,"date":3403,"series":3388,"seriesOrder":485},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-25","软件分析与设计笔记 25：代码重构：坏味道与手法","重构不改变外部行为、只改善内部结构：四种常见坏味道与对应手法、为什么动手之前必须先有测试、什么时候该重构，以及重构和前面那些设计模型是什么关系。","2026-11-16",{"path":3405,"title":3406,"description":3407,"date":3408,"series":3388,"seriesOrder":467},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-24","软件分析与设计笔记 24：设计原则：内聚、耦合与多态","好设计怎么衡量：内聚与耦合分别在说什么、一次从「差设计」到「多态重设计」的完整对照、抽象类与接口的选择依据，以及复用为什么会从优势变成风险。","2026-11-15",{"path":3410,"title":3411,"description":3412,"date":3413,"series":3388,"seriesOrder":458},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-23","软件分析与设计笔记 23：设计建模：从对象模型到能实现的方案","分析模型能用但还不能直接开工：对象模型怎么映射到关系模型、继承映射的三种方案怎么选、数据访问层与 Data Broker 解决什么问题，以及三层架构与 MVC 的分工。","2026-11-14",{"path":3415,"title":3416,"description":3417,"date":3418,"series":3388,"seriesOrder":453},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-22","软件分析与设计笔记 22：分析建模：边界、实体与控制对象","不经过分析直接写代码会踩什么坑、三种分析对象各管一段、怎么用顺序图完成一次用例实现，以及从顺序图导出类的属性、操作与关系的方法。","2026-11-13",{"path":3420,"title":3421,"description":3422,"date":3423,"series":3388,"seriesOrder":445},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-21","软件分析与设计笔记 21：UML：类图与顺序图","为什么需要一种共同的画法：类符号三格怎么画、五种关系的符号与设计含义、每种关系落到 Java 是什么写法，顺序图的三要素怎么摆；以及这三种最容易找不到的符号在 draw.io 里各搜什么词。","2026-11-12",{"path":3425,"title":3426,"description":3427,"date":3428,"series":3388,"seriesOrder":440},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-20","软件分析与设计笔记 20：面向对象概念：对象、关系与多态","面向对象不是「用某个语言写代码」：对象与类的区别、封装真正在保护什么、五种关系（依赖、关联、聚合、组合、继承）各自何时用，以及多态为什么能减少条件判断。","2026-11-11",{"path":3430,"title":3431,"description":3432,"date":3433,"series":3388,"seriesOrder":432},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-19","软件分析与设计笔记 19：规范化与逻辑数据模型","一次改地址引发的数据不一致，讲清规范化的真正动机；UNF 到 3NF 每一步在解决什么问题，派生字段为什么不能存，优化为什么和规范化方向相反，以及逻辑数据模型在 3NF 之上补了什么。","2026-11-10",{"path":3435,"title":3436,"description":3437,"date":3438,"series":3388,"seriesOrder":424},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-18","软件分析与设计笔记 18：数据库分析：实体、关系与数据字典","为什么不能拿到需求就动手建表：实体与关系怎么判定、基数为什么必须由业务方确认、数据命名要遵守什么规则，以及数据字典四列里最容易被空着的那一列。","2026-11-09",{"path":3440,"title":3441,"description":3442,"date":3443,"series":3388,"seriesOrder":416},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-17","软件分析与设计笔记 17：用生成式 AI 做设计与开发：能用的场景与要防的坑","生成式 AI 在工作流里真正站得住的三个场景 —— 造示例数据、解释陌生代码、起草初稿；它为什么会复现社会偏见、为什么会编得像真的，以及哪些内容绝不能直接采用。","2026-11-08",{"path":3445,"title":3446,"description":3447,"date":3448,"series":3388,"seriesOrder":408},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-16","软件分析与设计笔记 16：从 persona 到 URS：一份交付物的完整骨架","把前面十几篇串成一份能交出去的东西：用户需求规格说明书的九个部分、每一章来自哪个环节、由谁确认，以及面向决策者的提案该讲清哪三件事。","2026-11-07",{"path":3450,"title":3451,"description":3452,"date":3453,"series":3388,"seriesOrder":400},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-15","软件分析与设计笔记 15：非功能需求：把「快、稳、安全」变成数字","为什么「系统要快」不是一条需求：非功能需求怎么量化，决定性能的用户量、交易量、数据量三张表怎么估，安全需求要写清什么，以及可靠性的「不可用」该怎么定义。","2026-11-06",{"path":3455,"title":3456,"description":3457,"date":3458,"series":3388,"seriesOrder":392},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-14","软件分析与设计笔记 14：软件测试：层级、方法与等价类划分","「我们测过了」这句话问题在哪：为什么静态评审和动态测试缺一不可，五个测试层级各自验证什么，白盒与黑盒的差别，以及用等价类划分和边界值把无限输入变成有限用例的方法。","2026-11-05",{"path":3460,"title":3461,"description":3462,"date":3463,"series":3388,"seriesOrder":384},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-13","软件分析与设计笔记 13：需求获取：五种调查方法与一场访谈","需求从哪里来：五种事实调查方法各自适合回答什么、代价是什么，访谈从准备到记录的全过程，问卷设计里最容易犯的错，以及访谈记录必须写下来的那几项信息。","2026-11-04",{"path":3465,"title":3466,"description":3467,"date":3468,"series":3388,"seriesOrder":376},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-12","软件分析与设计笔记 12：前端设计：把流程变成屏幕","界面设计不是「画得好看」：从活动图走到屏幕的三步，CRUD 任务流怎么当检查表，界面必须回答的三个问题，信息架构与五种导航类型，视觉一致性与留白，以及移动端与 Web 端的差别。","2026-11-03",{"path":3470,"title":3471,"description":3472,"date":3473,"series":3388,"seriesOrder":368},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-11","软件分析与设计笔记 11：Agentic AI 与业务流程自动化","AI agent 到底是什么：五项能力与四个核心组件，什么场景该用定时任务、什么该用程序、什么才值得用 agent，两种实现路径的差别，必须提前防住的四类失败模式，以及设计方法从提示词到编排的演进。","2026-11-02",{"path":3475,"title":3476,"description":3477,"date":3478,"series":3388,"seriesOrder":360},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-10","软件分析与设计笔记 10：精炼用例模型：include、extend 与泛化","三种精炼用例关系的判断标准：include 什么时候用、它和 extend 的区别到底在哪、参与者泛化与用例泛化各解决什么问题，以及精炼到什么程度就该停手。","2026-11-01",{"path":3480,"title":3481,"description":3482,"date":3483,"series":3388,"seriesOrder":352},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-09","软件分析与设计笔记 09：功能需求（二）：从流程到业务实体","活动图画完之后，怎么从流程里筛出真正的业务实体：三条判断标准、哪些名词其实不该建实体、属性从哪里来，以及领域模型和后面的数据模型分别负责什么。","2026-10-31",{"path":3485,"title":3486,"description":3487,"date":3488,"series":3388,"seriesOrder":344},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-08","软件分析与设计笔记 08：功能需求（一）：用例模型与活动图","从用户故事到用例的翻译过程：什么才算「好」的参与者，系统边界怎么划，用例描述必须写清哪四件事，活动图怎么把分支、并行与泳道画清楚，以及这两个图在 draw.io 里各用哪个形状、最容易画错哪一笔。","2026-10-30",{"path":3490,"title":3491,"description":3492,"date":3493,"series":3388,"seriesOrder":336},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-07","软件分析与设计笔记 07：用户故事：把需求写成可验收的一句话","用户故事的三段结构，怎么从用户画像和数据地图里把它提取出来，一条故事多大算合适、时序为什么也是需求的一部分，以及验收标准怎么写才算真的可验收。","2026-10-29",{"path":3495,"title":3496,"description":3497,"date":3498,"series":3388,"seriesOrder":328},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-06","软件分析与设计笔记 06：范围界定（数据侧）：用数据校验你的判断","访谈结论和数字对不上时该怎么办：分析的四个层次分别在回答什么问题、为什么不能跳级、数据准备与搜集怎么做成一张应用地图，以及数据最容易在哪几个地方骗你。","2026-10-28",{"path":3500,"title":3501,"description":3502,"date":3503,"series":3388,"seriesOrder":320},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-05","软件分析与设计笔记 05：范围界定（用户侧）：用户画像与客户旅程图","为什么「所有用户」是一个陷阱，项目边界该怎么划，用户画像要写哪五个要素、最容易写成什么废纸，客户旅程图怎么把阶段、想法和情绪画在同一张纸上，以及怎么从这两样东西一步步推到可验证的需求。","2026-10-27",{"path":3505,"title":3506,"description":3507,"date":3508,"series":3388,"seriesOrder":312},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-04","软件分析与设计笔记 04：接手陌生案例的第一步：三问与一张表","拿到一个完全陌生的业务案例，怎么从零开始不跑偏：只问 Who、What、Why 三个问题，把「问题—事实—假设—理由」记成一张四列表，说清为什么假设必须写明理由，以及带着问题进访谈为什么比空手去有效得多。","2026-10-26",{"path":3510,"title":3511,"description":3512,"date":3513,"series":3388,"seriesOrder":303},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-03","软件分析与设计笔记 03：Agile 与 Scrum：迭代、角色与产出物","从「三周做完交付，客户说这不是我要的」讲起，说清瀑布式与迭代式的根本差别，Scrum 的三个角色和三种产出物，一个 sprint 里四次固定活动各自在解决什么问题，以及敏捷里最常见的几个误解。","2026-10-25",{"path":3515,"title":3516,"description":3517,"date":3518,"series":3388,"seriesOrder":296},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-02","软件分析与设计笔记 02：设计思维、用户驱动与数据驱动","从「客服要一个能一次看完所有排期的界面」这个翻车案例讲起，说清设计思维到底是什么、用户驱动与数据驱动各自能看见什么又看不见什么、为什么真正的落点是把两者合起来的 data-informed design，以及访谈中怎么问出有用的信息。","2026-10-24",{"path":3520,"title":3521,"description":3522,"date":3523,"series":3388,"seriesOrder":285},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-01","软件分析与设计笔记 01：课程地图与从需求到设计的主干","这一篇不写具体方法，先把整条链路铺开：软件分析与设计到底要解决什么问题，从需求到设计分成哪两段，中间有哪十几个环节，一份完整的交付物里应该有什么，以及后面 27 篇笔记怎么排、新人该按什么顺序读。","2026-10-23",{"path":3525,"title":3526,"description":3527,"date":3368,"series":3375,"seriesOrder":285},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-01","Spring 学习笔记 01：IoC、依赖注入与第一个 Spring Boot 应用","从「手写 new 出来的三层结构」的痛点讲起，说清 IoC 容器、Bean 与依赖注入是怎么回事，再用 Maven 与 Spring Initializr 跑起第一个能返回 JSON 的应用。",{"path":3529,"title":3530,"description":3531,"date":3368,"series":3375,"seriesOrder":296},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-02","Spring 学习笔记 02：Spring Data JPA 基础——实体映射与 Repository","用 ORM 把类映射到表：@Entity、@Id、@GeneratedValue 怎么用，CrudRepository 三层接口怎么选，H2 与 MySQL 怎么配，以及 ddl-auto 到底该写哪个值。",{"path":3533,"title":3534,"description":3535,"date":3368,"series":3375,"seriesOrder":303},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-03","Spring 学习笔记 03：JPA 查询——派生方法、JPQL 与分页排序","Repository 自带的方法只够按主键查，这篇讲三种自己造查询的方式：方法名派生、@Query 写 JPQL、原生 SQL，以及 Pageable 分页排序和投影返回。",{"path":3537,"title":3538,"description":3539,"date":3368,"series":3375,"seriesOrder":312},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-04","Spring 学习笔记 04：JPA 关联映射与仓储测试","一对多、多对多怎么映射，谁负责维护外键，FetchType 与 CascadeType 该怎么选，顺带用 @DataJpaTest 与 AssertJ 把仓储层测起来。",{"path":3541,"title":3542,"description":3543,"date":3368,"series":3375,"seriesOrder":320},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-05","Spring 学习笔记 05：Spring MVC 与 Thymeleaf 入门","一次页面请求在 Spring MVC 里走了哪几步，@Controller 怎么把数据交给模板，@PathVariable 与 @RequestParam 怎么取参数，以及控制器为什么必须是无状态的。",{"path":3545,"title":3546,"description":3547,"date":3368,"series":3375,"seriesOrder":328},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-06","Spring 学习笔记 06：表单绑定与服务端校验","把表单字段绑到对象上，用 Bean Validation 注解做服务端校验，把错误信息显示回用户刚填的那一页，再补上自定义校验注解与全局异常处理。",{"path":3549,"title":3550,"description":3551,"date":3368,"series":3375,"seriesOrder":336},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-07","Spring 学习笔记 07：Thymeleaf 片段复用与国际化","用 th:fragment 与 th:insert\u002Fth:replace 把导航栏、页脚抽成片段，页面套用统一布局，再用 messages.properties 与 LocaleResolver 做中英文切换。",{"path":3553,"title":3554,"description":3555,"date":3368,"series":3375,"seriesOrder":344},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-08","Spring 学习笔记 08：HTTP Session 与会话状态管理","HTTP 无状态带来的问题，Cookie 与 Session 怎么分工，Spring 里读写会话的几种方式，以及会话固定、超时、多实例共享这些必须想清楚的事。",{"path":3557,"title":3558,"description":3559,"date":3368,"series":3375,"seriesOrder":352},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-09","Spring 学习笔记 09：拦截器 HandlerInterceptor","登录检查、权限判断、请求耗时统计这类横切逻辑该放哪：HandlerInterceptor 的三个回调各在什么时机执行，怎么注册与排除路径，以及它和过滤器的分工。",{"path":3561,"title":3562,"description":3563,"date":3368,"series":3375,"seriesOrder":360},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-10","Spring 学习笔记 10：Service 层与事务","一组数据库操作要么全成功要么全失败，靠 @Transactional 怎么做到；传播行为、隔离级别、回滚规则怎么选，以及自调用失效、乐观锁悲观锁这些实战里的坑。",{"path":3373,"title":5,"description":3369,"date":3368,"series":3375,"seriesOrder":368},{"path":3566,"title":3567,"description":3568,"date":3368,"series":3375,"seriesOrder":376},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-12","Spring 学习笔记 12：REST 进阶——版本、更新、跨域与文档化","接口能跑通之后要处理的几件事：URL 与响应结构怎么定、版本怎么演进、更新怎么做到可安全重试、CORS 怎么配、HATEOAS 与 Spring Data REST 该不该用。",{"path":3570,"title":3571,"description":3572,"date":3573,"series":3574,"seriesOrder":312},"\u002Fposts\u002Fjava\u002Fjava-functionalprogramming-notes","Java 函数式编程笔记：Lambda、函数式接口与 Streams","学习 Java 函数式编程时的个人笔记：Lambda 表达式的五步简写与方法引用、常见函数式接口、Stream 流水线与惰性求值，以及创建流、中间操作、终止操作与收集器的用法。示例代码均为自写，运行结果来自本地实测。","2026-10-13","Java 学习笔记",{"path":3576,"title":3577,"description":3578,"date":3573,"series":3574,"seriesOrder":303},"\u002Fposts\u002Fjava\u002Fjava-generics-notes","Java 泛型笔记：泛型方法、有界类型与泛型类","学习 Java 泛型时的个人笔记：从重复的重载方法与多态尝试讲起，再到泛型方法与类型推断、有界泛型类型、Comparable 与 Number 边界，以及泛型类在集合中的用法。示例代码均为自写，运行结果来自本地实测。",{"path":3580,"title":3581,"description":3582,"date":3583,"series":3584,"seriesOrder":312},"\u002Fposts\u002Fmysql\u002Fmysql-learning-04","MySQL 学习笔记 04：数据操纵（增删改）","MySQL 学习笔记第四篇：INSERT 插入（含批量插入与 INSERT ... SELECT）、UPDATE 更新、DELETE 删除，以及各自的坑。","2026-10-10","SQL 学习笔记",{"path":3586,"title":3587,"description":3588,"date":3583,"series":3584,"seriesOrder":320},"\u002Fposts\u002Fmysql\u002Fmysql-learning-05","MySQL 学习笔记 05：DDL 概览","MySQL 学习笔记第五篇：数据定义语言概览——Schema、Table、View、Index、Constraints 这几类对象分别对应哪些 CREATE \u002F ALTER \u002F DROP 命令。",{"path":3590,"title":3591,"description":3592,"date":3583,"series":3584,"seriesOrder":328},"\u002Fposts\u002Fmysql\u002Fmysql-learning-06","MySQL 学习笔记 06：CREATE TABLE 与数据类型","MySQL 学习笔记第六篇：CREATE TABLE 的完整语法、常用数据类型的选型建议，以及外键的两种定义方式。",{"path":3594,"title":3595,"description":3596,"date":3583,"series":3584,"seriesOrder":336},"\u002Fposts\u002Fmysql\u002Fmysql-learning-07","MySQL 学习笔记 07：索引","MySQL 学习笔记第七篇：索引的作用与 B+ 树结构、单列与复合索引的创建、索引的代价与适用场景。",{"path":3598,"title":3599,"description":3600,"date":3583,"series":3584,"seriesOrder":344},"\u002Fposts\u002Fmysql\u002Fmysql-learning-08","MySQL 学习笔记 08：DROP 与 ALTER","MySQL 学习笔记第八篇：DROP 删除数据库对象、ALTER 修改表结构，以及常见的坑。",{"path":3602,"title":3603,"description":3604,"date":3583,"series":3584,"seriesOrder":352},"\u002Fposts\u002Fmysql\u002Fmysql-learning-09","MySQL 学习笔记 09：约束与数据完整性","MySQL 学习笔记第九篇：SQL 约束——NOT NULL、CHECK、PRIMARY KEY、FOREIGN KEY 等如何保证数据完整性，以及它们的对比。",{"path":3606,"title":3607,"description":3608,"date":3583,"series":3584,"seriesOrder":360},"\u002Fposts\u002Fmysql\u002Fmysql-learning-10","MySQL 学习笔记 10：视图","MySQL 学习笔记第十篇：视图（Views）的定义与特性、和表的对比、创建与使用，以及可更新视图的限制。",{"path":3610,"title":3611,"description":3612,"date":3583,"series":3584,"seriesOrder":368},"\u002Fposts\u002Fmysql\u002Fmysql-learning-11","MySQL 学习笔记 11：存储过程","MySQL 学习笔记第十一篇：存储过程（Stored Procedures）的概念、创建与调用、带参数的写法，以及优缺点。",{"path":3614,"title":3615,"description":3616,"date":3617,"series":3584,"seriesOrder":285},"\u002Fposts\u002Fmysql\u002Fmysql-learning-01","MySQL 学习笔记 01：MySQL 基础知识","MySQL 学习笔记第一篇：MySQL Server 与 Workbench 的安装，数据库与 DBMS 的基本概念，关系模型、ACID 事务、常见数据类型，以及后续的学习路线。","2026-10-09",{"path":3619,"title":3620,"description":3621,"date":3617,"series":3584,"seriesOrder":296},"\u002Fposts\u002Fmysql\u002Fmysql-learning-02","MySQL 学习笔记 02：SELECT 查询与过滤","MySQL 学习笔记第二篇：SELECT 的基本语法与逻辑执行顺序，WHERE 过滤（比较、LIKE、IN、BETWEEN、NULL）、各种 JOIN、UNION 与子查询。",{"path":3623,"title":3624,"description":3625,"date":3617,"series":3584,"seriesOrder":303},"\u002Fposts\u002Fmysql\u002Fmysql-learning-03","MySQL 学习笔记 03：聚合、分组与输出控制","MySQL 学习笔记第三篇：COUNT\u002FSUM\u002FAVG 等聚合函数、GROUP BY 分组、HAVING 过滤、ORDER BY 与 LIMIT，以及一条 SELECT 的完整逻辑执行顺序。",{"path":3627,"title":3628,"description":3629,"date":3630,"series":3574,"seriesOrder":296},"\u002Fposts\u002Fjava\u002Fjava-oop-notes","Java 面向对象笔记：类、继承、多态与异常","学习 Java 面向对象编程时的个人笔记：类与对象、封装与访问修饰符、继承与多态、集合、抽象类与接口、异常处理。示例代码均为自写，运行结果来自本地实测。","2026-10-06",{"path":3632,"title":3633,"description":3634,"date":3635,"series":3574,"seriesOrder":285},"\u002Fposts\u002Fjava\u002Fjava-basics-notes","Java 基础语法笔记：从环境安装到方法与数组","学习 Java 基础语法时的个人笔记：JDK 安装、输入输出、变量与运算符、条件与循环、字符串、数组与方法。示例代码均为自写，运行结果来自本地实测。","2026-10-05",{"path":3637,"title":3638,"description":3639,"date":3640,"series":3641,"seriesOrder":285},"\u002Fposts\u002Fgit\u002Fgit-install-and-basics","Git 安装与基本使用","从装好 Git 到跑通日常提交流程，把最常用的几条命令和它们背后的三个区域讲清楚。","2026-10-04","Git 入门",{"path":3643,"title":3644,"description":3645,"date":3640,"series":3641,"seriesOrder":296},"\u002Fposts\u002Fgit\u002Flocal-remote-repository","本地远程仓库的部署","用自己的机器当 Git 服务器：建一个裸仓库、走 SSH 连上去，把本地提交推上去。",{"path":3647,"title":3648,"description":3649,"date":3640,"series":3650,"seriesOrder":285},"\u002Fposts\u002Fguide\u002Fguide-file-layout","文件都放在哪","文章、图片、封面分别放在仓库的什么位置，哪些目录可以放手改、哪些碰了会出问题。","博客使用指南",{"path":3652,"title":3653,"description":3654,"date":3640,"series":3650,"seriesOrder":296},"\u002Fposts\u002Fguide\u002Fguide-frontmatter","frontmatter 每个字段是什么意思","文章开头那段被横线包住的字段，逐个说明作用、默认行为和写错的后果。",{"path":3656,"title":3657,"description":3658,"date":3640,"series":3650,"seriesOrder":303},"\u002Fposts\u002Fguide\u002Fguide-markdown","正文怎么写 —— Markdown 与排版效果","标题、链接、图片、代码、表格分别怎么写，以及本站对它们做了哪些自定义处理。",1793268186017]