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