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