:從JWT鑒權到部署實戰(zhàn))
做競賽管理系統(tǒng)這個選題我一直覺得是前后端分離項目里性價比最高的一類。業(yè)務模型清晰、角色邊界明確、數(shù)據(jù)流轉(zhuǎn)完整從用戶到權限再到文件上傳、狀態(tài)流轉(zhuǎn)、統(tǒng)計報表全都能覆蓋到。尤其對于正在做畢業(yè)設計或者想系統(tǒng)練手全棧的同學來說一套基于SpringBoot Vue的大學生競賽管理系統(tǒng)幾乎把企業(yè)級開發(fā)里最常見的那些坑都踩了一遍JWT鑒權怎么做、文件上傳怎么接、跨域怎么配、前端打包怎么塞進后端、路由守衛(wèi)怎么攔截。這篇文章就把我這套系統(tǒng)的完整思路、核心代碼、表設計和部署經(jīng)驗一次性講清楚照著做你也能從零搭出一套能跑、能答辯、能演示的完整項目。1. 整體設計與技術選型先把業(yè)務想明白再動手1.1 角色劃分與核心業(yè)務流程競賽管理系統(tǒng)這個業(yè)務本質(zhì)上就是“賽事全流程管理”。從管理員發(fā)布競賽到學生報名、上傳作品再到評委打分、成績公示、證書下載一條線走完。傳統(tǒng)的紙筆流程最大的問題是信息不透明學生不知道報名是否成功、評委之間分數(shù)互相看不到、管理員統(tǒng)計成績?nèi)縀xcel。系統(tǒng)把這些環(huán)節(jié)數(shù)字化之后每步操作都有狀態(tài)記錄和時間戳權責清晰審計也方便。我在設計角色時劃分了三種學生瀏覽競賽列表、查看詳情、報名參賽、上傳作品、查看自己的成績和證書。評委/教師對分配到名下的作品打分、填寫評語、查看已評和待評列表。管理員用戶管理、競賽信息發(fā)布與狀態(tài)控制、報名審核、評委分配、成績統(tǒng)計與導出。這三種角色的權限邊界要非常明確。學生不能看到評委打分頁評委不能修改競賽信息管理員不參與打分。權限控制如果做不好后續(xù)所有功能都會出現(xiàn)越權訪問的漏洞。1.2 技術棧選型的三個理由這套系統(tǒng)我選擇的是SpringBoot 2.7.x Vue 3 Element Plus MySQL 8 MyBatis Plus。選這套組合不是跟風而是有幾個實際考量。第一個考量是SpringBoot版本。2.7.x是目前兼容性最穩(wěn)的版本JDK 8和JDK 11都能跑各種第三方starter基本都能找到對應版本。SpringBoot 3.0之后強制要求JDK 17很多學生本機的JDK版本還是8一上來就報各種版本兼容錯誤光環(huán)境問題就能卡一整天。當然如果你本機已經(jīng)是JDK 17直接用3.x也沒問題只是要注意javax命名空間改成jakartaMyBatis Plus和相關依賴也要用適配版本。第二個考量是前端框架版本。Vue 3 Vite Element Plus是當前的主流組合Vite的啟動速度比Webpack快一個量級開發(fā)體驗好很多。組件庫用Element Plus界面風格統(tǒng)一表格、表單、彈窗、上傳組件都是現(xiàn)成的不用從零寫UI。如果你更熟悉Vue 2 Element UI思路完全一樣只是API細節(jié)略有差異不影響整體架構。第三個考量是持久層框架。MyBatis Plus在單表CRUD場景下幾乎不用寫SQL自帶分頁插件和條件構造器開發(fā)效率非常高。競賽管理系統(tǒng)里大部分查詢都是單表條件查詢比如“查詢某個競賽下所有報名記錄”“查詢當前用戶的所有作品”用LambdaQueryWrapper幾行代碼就搞定了沒必要手寫復雜的XML映射。1.3 數(shù)據(jù)庫表設計的六個核心表數(shù)據(jù)庫是整個系統(tǒng)的地基我踩過最深的坑就是表設計太隨意改表結構比改代碼痛苦十倍。競賽系統(tǒng)核心表我設計如下前后端聯(lián)調(diào)階段的“接口字段和數(shù)據(jù)庫字段對不上”問題源頭就在這里。表名核心字段說明sys_userid, username, password, nickname, role, avatar, college, major, student_no, phone, email, status, create_time用戶表role區(qū)分admin/teacher/studentcontestid, title, description, category, cover, max_team_members, registration_start_time, registration_end_time, contest_start_time, contest_end_time, status, create_time競賽表status字段控制生命周期contest_registrationid, contest_id, user_id, team_name, team_members, contact_phone, status, create_time報名表status分為待審核/已通過/已拒絕work_submissionid, contest_id, user_id, registration_id, title, description, file_url, cover_url, submit_time, status作品表一個報名對應一個作品contest_scoreid, work_id, reviewer_id, score, comment, create_time評分表同一作品多個評委打分后取平均noticeid, title, content, type, owner_id, publish_time新聞公告表前臺輪播和消息通知用重點說一下競賽的status字段。我用了數(shù)字字典0表示草稿、1表示報名中、2表示評審中、3表示已結束。前端根據(jù)這個值控制按鈕的可用狀態(tài)后端在報名接口也校驗當前時間是否在報名窗口內(nèi)雙重保險避免有人繞過前端直接調(diào)接口。另外一個關鍵設計是work_submission里的registration_id它把報名表和作品表關聯(lián)起來。這樣查詢“某學生報名了哪些競賽”和“某競賽收了哪些作品”都非常方便。前期表設計時把外鍵關系理清后面寫查詢就是順水推舟的事。2. 后端核心模塊從登錄鑒權到文件上傳的完整閉環(huán)2.1 統(tǒng)一響應體與全局異常處理前后端分離項目接口返回格式必須統(tǒng)一。我封裝了一個Result類所有接口返回{code, message, data}的結構。code為200表示成功401表示未登錄403表示無權限500表示業(yè)務異常。這個封裝看起來簡單但能讓前端攔截器的處理邏輯變得非常整潔。Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }有了統(tǒng)一響應體還需要一個全局異常處理器把業(yè)務異常和系統(tǒng)異常分開處理。我是用RestControllerAdvice注解實現(xiàn)的捕獲自定義的BusinessException拋出的異常時返回對應的錯誤碼和提示語捕獲Exception時統(tǒng)一返回500和“系統(tǒng)異常請稍后重試”避免把堆棧信息直接暴露給前端。這里有個開發(fā)習慣值得養(yǎng)成不要把try-catch寫在每個Controller里而是讓業(yè)務層拋出異常由全局處理器統(tǒng)一處理。代碼會干凈很多也不容易漏掉異常分支。2.2 Spring Security JWT登錄鑒權實戰(zhàn)登錄鑒權是這類系統(tǒng)的重中之重。我用的是JWT無狀態(tài)方案用戶登錄成功后后端簽發(fā)一個包含用戶ID、用戶名、角色信息的Token前端請求時放在請求頭的Authorization字段里后端攔截器解析Token并校驗身份。這樣做的好處是服務端不需要存儲Session水平擴展時不需要做Session共享對部署和將來合入網(wǎng)關都很友好。JWT工具類核心代碼如下注意設置過期時間和密鑰Component public class JwtUtils { private static final String SECRET your-secret-key-your-secret-key; private static final long EXPIRATION 1000 * 60 * 60 * 24 * 7L; public String generateToken(Integer userId, String username, String role) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .claim(role, role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRATION)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }Security配置類是這個模塊中最容易出錯的地方。需要放行登錄接口、驗證碼接口和靜態(tài)資源路徑其余接口全部走JWT過濾器。放行哪些路徑看似簡單但如果你把接口路徑寫錯或者放行多了后面調(diào)接口時就會出現(xiàn)“明明登錄成功了卻一直401”的詭異問題。Configuration EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/api/auth/login, /api/upload/**, /files/**).permitAll() .antMatchers(/api/admin/**).hasRole(admin) .antMatchers(/api/teacher/**).hasRole(teacher) .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); } }角色和路徑的匹配規(guī)則要提前規(guī)劃。我在實際開發(fā)中把接口路徑做了約定/api/student/**學生接口、/api/teacher/**評委接口、/api/admin/**管理接口、/api/common/**公共接口。前端的API請求也遵循這個約定后端的鑒權配置就非常清晰。2.3 競賽報名與作品提交狀態(tài)機與并發(fā)控制報名和作品提交是競賽系統(tǒng)的核心業(yè)務操作這兩個接口在并發(fā)場景下特別容易出現(xiàn)臟數(shù)據(jù)。比如同一個學生重復提交報名或者報名截止時間剛過但請求還在路上。我處理的思路是雙保險數(shù)據(jù)庫層面加唯一索引代碼層面做業(yè)務校驗。報名表上加UNIQUE KEY uk_contest_user (contest_id, user_id)這樣即使兩個并發(fā)請求同時通過代碼校驗數(shù)據(jù)庫也會攔截重復插入返回DuplicateKeyException。代碼里再判斷當前時間是否在報名窗口期、競賽狀態(tài)是否為“報名中”雙重保障確保數(shù)據(jù)正確。作品提交的邏輯稍微復雜一點。學生在報名審核通過后才能上傳作品上傳后可以修改截止時間后鎖定。我設計了works表的status字段0表示未提交、1表示已提交待評審、2表示已退回修改。評委退回作品后學生重新上傳狀態(tài)回到1進入評審隊列。這個狀態(tài)機設計是整個系統(tǒng)業(yè)務邏輯里最容易寫亂的地方。我強烈建議先在紙上畫出狀態(tài)流轉(zhuǎn)圖待審核-已通過-已提交-已退回-已提交每個狀態(tài)觸發(fā)什么事件、誰觸發(fā)、產(chǎn)生什么結果搞清楚再寫代碼。不然代碼越寫越亂改一個狀態(tài)還要連帶著改三四處邏輯。2.4 文件上傳與靜態(tài)資源映射競賽作品通常是PDF、壓縮包或者圖片文件上傳功能必不可少。SpringBoot的文件上傳配置很簡潔spring: servlet: multipart: max-file-size: 100MB max-request-size: 200MB文件上傳的Controller稍微有點講究需要做文件類型白名單校驗、文件名重命名防路徑穿越、按日期歸檔存儲。我的實現(xiàn)邏輯是接收MultipartFile校驗后綴名是否在允許列表里用UUID重新生成文件名存儲到uploads/yyyy/MM/dd/目錄下返回數(shù)據(jù)庫里的相對路徑。相對路徑加上域名前綴就是完整的訪問URL。前端上傳PDF后需要預覽這里有一個很實用的技巧如果瀏覽器可以直接打開PDF用iframe嵌; 但要注意Element Plus的upload組件默認會走form-data提交后端返回的JSON需要放在response里而不是自定義header里否則回顯會有兼容問題。這個我后面在前端章節(jié)再展開講。3. 前端Vue實踐從腳手架到核心交互3.1 項目結構與路由設計前端項目我用Vite腳手架創(chuàng)建推薦的目錄結構如下src/ api/ # 接口請求封裝 auth.js contest.js work.js assets/ # 靜態(tài)資源 components/ # 公共組件 CommonTable.vue FileUpload.vue router/ # 路由配置 index.js store/ # Pinia狀態(tài)管理 user.js views/ admin/ # 管理端頁面 student/ # 學生端頁面 teacher/ # 評委端頁面 Login.vue Home.vue路由設計上我用了動態(tài)路由和路由守衛(wèi)結合的方式。靜態(tài)路由只有登錄頁和首頁其他頁面根據(jù)用戶角色動態(tài)生成。這樣做的好處是角色菜單天然分離學生登錄后看不到評委頁面同時也減少了首屏加載的路由數(shù)量。路由守衛(wèi)是必須寫的不然用戶手動改URL就能跳到?jīng)]權限的頁面router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() } else if (!token) { next(/login) } else { const userStore useUserStore() if (!userStore.userInfo) { userStore.fetchUserInfo().then(() { next() }).catch(() { next(/login) }) } else { next() } } })3.2 Axios請求封裝與Token刷新邏輯Axios封裝這項工作看似簡單但卻是前后端聯(lián)調(diào)時體驗好不好的決定性因素。我的封裝思路是創(chuàng)建axios實例設置baseURL為/api請求攔截器里從localStorage取Token并放入Authorization頭響應攔截器里統(tǒng)一處理HTTP狀態(tài)碼和業(yè)務狀態(tài)碼。const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer ${token} } return config }) service.interceptors.response.use( response { const res response.data if (res.code 200) { return res } if (res.code 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(new Error(res.message)) }, error { return Promise.reject(error) } )特別是401的處理很多新手容易漏掉。Token過期后如果只是彈個“請重新登錄”的提示用戶就卡在頁面上不知道該怎么辦。跳轉(zhuǎn)到登錄頁并清空本地存儲是最直觀的處理方式。3.3 核心頁面交互競賽列表、詳情與倒計時競賽列表頁是學生接觸系統(tǒng)的第一屏交互友好度直接影響使用體驗。我用卡片形式展示競賽信息每個卡片包含封面圖、標題、競賽類別、報名截止時間、當前狀態(tài)標簽。點擊卡片跳轉(zhuǎn)到詳情頁路由傳遞競賽ID參數(shù)。這里最考驗細節(jié)的是競賽狀態(tài)與按鈕的聯(lián)動。報名按鈕要依據(jù)后端返回的status字段和當前時間動態(tài)計算status為1且當前時間在報名窗口內(nèi)顯示“立即報名”不在窗口內(nèi)顯示“報名未開始”或“已截止”status為2顯示“評審中”status為3顯示“已結束”。這些狀態(tài)計算我放在前端寫了一個工具函數(shù)export function getContestStatus(contest) { const now Date.now() const start new Date(contest.registrationStartTime).getTime() const end new Date(contest.registrationEndTime).getTime() if (contest.status 0) return { text: 未發(fā)布, type: info } if (contest.status 1 now start) return { text: 報名未開始, type: warning } if (contest.status 1 now start now end) return { text: 報名中, type: success } if (contest.status 1 now end) return { text: 報名已截止, type: danger } if (contest.status 2) return { text: 評審中, type: warning } if (contest.status 3) return { text: 已結束, type: info } }詳情頁我同時展示了競賽介紹、時間線、賽事流程說明。倒計時組件用setInterval實現(xiàn)每秒鐘更新剩余時間頁面銷毀時記得清除定時器。這個組件雖小但踩過的坑是路由切換后定時器沒清掉導致組件銷毀后還在執(zhí)行setState控制臺報一堆警告。3.4 文件上傳組件與PDF預覽系統(tǒng)最核心的上傳場景是作品提交以PDF為主。Element Plus的el-upload組件提供了拖拽上傳和進度顯示但默認行為是選擇文件后立即上傳。我配置了:auto-uploadfalse用戶選擇文件后先展示在文件列表里點擊“確認提交”才真正發(fā)起上傳避免誤選后無法撤銷。上傳成功后預覽PDF我用了兩種方案兼容不同場景。如果瀏覽器原生支持PDF預覽直接新窗口打開文件URL就行。為了更好的展示體驗我在前端頁面內(nèi)嵌了iframe將PDF文件URL作為src實測Chrome和Edge都能正常顯示。如果需要更精細的控制比如指定頁碼、縮放可以使用pdf.js或者vue-pdf組件不過對于競賽管理系統(tǒng)的場景iframe方案已經(jīng)足夠還省了一個大依賴。用戶的頭像預覽也是一樣的邏輯文件上傳后返回相對路徑前端拼接成完整URL用于img標簽的src。這里注意一個問題開發(fā)環(huán)境下后端接口在8080端口前端在5173端口圖片URL如果是相對路徑瀏覽器會去5173端口找就404了。解決辦法是用Vite的代理配置把/api和/files都代理到后端地址。4. 環(huán)境搭建、聯(lián)調(diào)與部署實戰(zhàn)4.1 本地環(huán)境準備與依賴配置開始寫代碼之前先把環(huán)境搞定不然后面踩的坑都是環(huán)境的鍋。我建議本地環(huán)境如下JDK 8 或 11對應SpringBoot 2.7.xMaven 3.6配置阿里云鏡像源否則依賴下載慢到懷疑人生Node 16Vite 4要求Node 14.18我推薦Node 16長期支持版MySQL 8.0字符集全部設置為utf8mb4否則中文亂碼Maven的pom.xml核心依賴清單如下注意MyBatis Plus和SpringBoot的版本兼容關系parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies4.2 前后端聯(lián)調(diào)跨域問題與接口規(guī)范前后端分離項目跨域問題幾乎必然遇到。開發(fā)環(huán)境最省事的方案是Vite代理在vite.config.js里配置export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true }, /files: { target: http://localhost:8080, changeOrigin: true } } } })配置了代理之后前端代碼里所有請求都寫相對路徑/api/xxx由Vite轉(zhuǎn)發(fā)到8080端口。這種方式比后端配置CORS要優(yōu)雅得多因為生產(chǎn)環(huán)境前端已經(jīng)打包進后端工程本身就是同源的不存在跨域。如果后端非要用CORS我推薦寫一個WebMvcConfigurer配置類而不是加CrossOrigin注解。CORS配置類寫法如下注意要同時配置allowedOriginPatterns和allowedMethods否則預檢請求會失敗Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }聯(lián)調(diào)階段另一個高頻問題是時間格式不一致。后端實體類LocalDateTime序列化后默認是一長串數(shù)組前端無法直接渲染。需要在application.yml里配置Jackson的日期格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT84.3 Vue項目打包放進SpringBoot開發(fā)完成后部署最直接的方式是把Vue打包生成的dist目錄塞進SpringBoot的靜態(tài)資源目錄。這個過程看似簡單但有兩個坑特別常見。第一步是修改Vite的base配置默認是/打包后資源路徑是絕對路徑部署到服務器子路徑時會找不到靜態(tài)資源。我建議改成相對路徑export default defineConfig({ base: ./, build: { outDir: dist, assetsDir: static } })第二步是路由模式。如果前端路由用了history模式刷新頁面時后端沒有對應的路由處理器就會出現(xiàn)404。最簡單的方案是后端加一個轉(zhuǎn)發(fā)規(guī)則未匹配到的路徑全部轉(zhuǎn)發(fā)到index.html。如果不想動后端前端路由改用hash模式URL多一個#號但刷新永遠正常。個人推薦hash模式省心。把dist下的文件復制到SpringBoot的src/main/resources/static目錄然后啟動后端訪問http://localhost:8080/就是完整系統(tǒng)。這樣整個應用只有一個Jar包部署到服務器只需要Java環(huán)境不用單獨裝Nginx。4.4 常見報錯排查速查表寫這套系統(tǒng)的過程中我把遇到過的高頻報錯整理成了一個速查表對照排查能省大量時間?,F(xiàn)象可能原因解決方案前端請求后端404代理沒配或路徑拼錯檢查vite.config.js代理配置和API路徑前綴登錄后調(diào)用接口返回403Security配置放行路徑不完整檢查JWT過濾器注冊順序和路徑匹配規(guī)則文件上傳報錯超過大小限制后端multipart配置未生效檢查yaml配置中max-file-size和max-request-size中文亂碼數(shù)據(jù)庫字符集不是utf8mb4建庫時指定utf8mb4連接串加characterEncodingutf8刷新頁面404前端history路由沒配轉(zhuǎn)發(fā)用hash模式或后端加轉(zhuǎn)發(fā)規(guī)則Token過期后接口一直報錯前端沒做401統(tǒng)一處理在響應攔截器里統(tǒng)一跳轉(zhuǎn)登錄頁Vue啟動報Node版本不兼容Node版本過低升級到Node 16或降低Vite版本還有幾個容易忽略的小問題。SpringBoot啟動時如果端口被占用控制臺會直接報端口綁定失敗搜索哪個進程占了8080端口殺掉或者改端口。MyBatis Plus分頁插件需要單獨配置PaginationInterceptor不配置的話分頁不生效默認只查一條。另外前端打包時如果報內(nèi)存溢出需要在package.json里配置build: vite build --max-old-space-size4096或者Node設置的NODE_OPTIONS變量。最后聊一點管理后臺的統(tǒng)計報表功能。競賽系統(tǒng)的管理者非常關注每個競賽的報名人數(shù)、作品提交率、各學院參與人數(shù)分布。這些統(tǒng)計我用ECharts做曲線圖和餅圖后端提供一個聚合查詢接口用MyBatis Plus的groupBy統(tǒng)計每個學院的人數(shù)。ECharts的引入方式很簡單npm安裝然后在需要用的組件里局部引入按需注冊圖表類型避免全量打包導致體積過大。還有評委打分環(huán)節(jié)的設計也要多說兩句。傳統(tǒng)做法是管理員給每個評委手動分配作品但作品數(shù)量一多就非常繁瑣。我的做法是先把作品按類別分組評委可以主動認領作品也可以由管理員批量分配。打分維度我拆成了創(chuàng)新性、完整性、實用性三個子項每個子項記分總分自動匯總再取多個評委的平均分作為最終成績。設置一個“成績公示”開關控制學生是否能在前臺看到自己的分數(shù)避免未出結果前分數(shù)泄露。這個項目后續(xù)可以考慮擴展的方向一個是接入消息通知模塊報名成功和成績公布時給用戶發(fā)送站內(nèi)信或者郵件另一個是增加數(shù)據(jù)可視化大屏頁面把全校的競賽參與情況用圖表形式展示出來這個對答辯演示的加分效果是很明顯的。再一個就是引入Redis緩存首頁的競賽列表和熱門賽事情報減少MySQL的查詢壓力。我個人在實際維護這套系統(tǒng)的感受是業(yè)務功能本身不難難點都在細節(jié)的邊界處理和數(shù)據(jù)狀態(tài)的流轉(zhuǎn)上。狀態(tài)機的設計、權限控制的粒度、文件上傳的健壯性、部署路徑的兼容性這四塊做好系統(tǒng)基本上就穩(wěn)了。你在照著實現(xiàn)的時候如果遇到某個接口調(diào)不通或者某個狀態(tài)不對先從數(shù)據(jù)表看一眼數(shù)據(jù)是不是臟了再往代碼里排查絕大多數(shù)問題都是數(shù)據(jù)狀態(tài)不符合預期導致的。