用存儲(chǔ)過(guò)程返回游標(biāo)實(shí)例:TaoToken 統(tǒng)一 Key 下的配置骨架與驗(yàn)證)
1. 為什么存儲(chǔ)過(guò)程返回游標(biāo)在 MyBatis 里總踩坑MyBatis 調(diào)用存儲(chǔ)過(guò)程返回游標(biāo)是很多做企業(yè)級(jí)報(bào)表、對(duì)賬、批量校驗(yàn)的同學(xué)繞不開(kāi)的場(chǎng)景。存儲(chǔ)過(guò)程里open v_Cursor for select ...把結(jié)果集通過(guò)OUT參數(shù)吐回來(lái)Java 側(cè)拿到的其實(shí)是一個(gè) JDBC 游標(biāo)對(duì)象而不是普通的List。如果你按普通select的寫(xiě)法去接大概率會(huì)拿到一個(gè)空集合或者直接拋ORA-01000、Invalid column type之類的異常。這篇聚焦一個(gè)真實(shí)可跑的實(shí)例Oracle 存儲(chǔ)過(guò)程Fsp_Plan_CheckPrj接收兩個(gè)入?yún)?、返回一個(gè)sys_refcursorMyBatis 的 mapper XML 用statementTypeCALLABLE聲明OUT參數(shù)用jdbcTypeCURSOR加resultMap映射列別名。同時(shí)我會(huì)把 MySQL 場(chǎng)景下沒(méi)有原生游標(biāo)、需要用OUT結(jié)果集或臨時(shí)表替代的差異也講清楚避免你從 Oracle 遷到 MySQL 時(shí)直接照抄報(bào)錯(cuò)。適合誰(shuí)看已經(jīng)會(huì)寫(xiě)基礎(chǔ) MyBatis CRUD、但第一次接觸CALLABLE和游標(biāo)映射的后端同學(xué)或者手上有個(gè)老項(xiàng)目存儲(chǔ)過(guò)程是 DBA 寫(xiě)好的你只負(fù)責(zé)在 Java 層把它調(diào)通。整篇的配置骨架我會(huì)用 TaoToken 統(tǒng)一 Key 來(lái)管理模型側(cè)和編碼側(cè)的調(diào)用憑證這樣你在調(diào)試 SQL 映射的同時(shí)也能順手把 AI 輔助編碼的入口配好不用在多個(gè)平臺(tái)之間來(lái)回切 Key。核心檢索詞先擺出來(lái)MyBatis 調(diào)用存儲(chǔ)過(guò)程返回游標(biāo)關(guān)鍵三件套是statementTypeCALLABLE、modeOUT jdbcTypeCURSOR、resultMap列映射。記住這三個(gè)后面所有報(bào)錯(cuò)基本都能定位到其中之一。2. TaoToken 前置統(tǒng)一 Key 與配置骨架在動(dòng)手寫(xiě) mapper 之前先把調(diào)用憑證這塊理順。TaoToken 的作用是把模型對(duì)話、編碼計(jì)劃、API Key 管理收斂到一個(gè)入口你只需要維護(hù)一份 Key就能在 IDE 插件、命令行工具、腳本里復(fù)用。官網(wǎng)入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 基址是 https://taotoken.net/api 注意 API 地址不帶 UTM 參數(shù)配置里填這個(gè)就行。先拿 Key進(jìn)入控制臺(tái) https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 在 API Keys 頁(yè)面創(chuàng)建一個(gè)新 Key復(fù)制出來(lái)。這個(gè) Key 就是后面所有配置里的apiKey字段。如果你用的是 Claude Code 這類編碼 Agent可以直接走 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 它會(huì)把長(zhǎng)期編碼任務(wù)的額度單獨(dú)管理不會(huì)和你臨時(shí)調(diào)試模型對(duì)話的消耗混在一起。配置骨架分兩種格式按你用的工具選。VS Code 系插件一般讀settings.json命令行工具或 Rust 系工具讀config.toml。下面兩份都是可復(fù)制的骨架把sk-你的Key替換成剛才創(chuàng)建的值即可。settings.json骨架{ taotoken.apiKey: sk-你的Key, taotoken.baseUrl: https://taotoken.net/api, taotoken.model: claude-sonnet, taotoken.timeout: 60000, taotoken.maxTokens: 8192 }config.toml骨架[taotoken] api_key sk-你的Key base_url https://taotoken.net/api model claude-sonnet timeout 60000 max_tokens 8192注意base_url只寫(xiě)到/api不要在后面拼/v1或/chat/completions具體路徑由客戶端自己補(bǔ)。填錯(cuò)這一層最常見(jiàn)的表現(xiàn)是 404而不是鑒權(quán)失敗排查時(shí)先看狀態(tài)碼。Key 創(chuàng)建頁(yè)在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 接入文檔在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。文檔里有各語(yǔ)言 SDK 的調(diào)用示例遇到參數(shù)名對(duì)不上時(shí)以文檔為準(zhǔn)。模型對(duì)話調(diào)試入口在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 你可以先在網(wǎng)頁(yè)里發(fā)一條消息確認(rèn) Key 有效再去配本地工具這樣能把「Key 問(wèn)題」和「工具配置問(wèn)題」分開(kāi)。3. 可復(fù)制配置mapper XML 與 Java 調(diào)用3.1 Oracle 存儲(chǔ)過(guò)程與 mapper XML先看存儲(chǔ)過(guò)程本身它接收v_grantno、v_deptcode兩個(gè)入?yún)⒌谌齻€(gè)是OUT游標(biāo)create or replace procedure Fsp_Plan_CheckPrj( v_grantno varchar2, v_deptcode number, v_cursor out sys_refcursor ) is begin open v_cursor for select s.plan_code, s.plan_dept, s.plan_amount, s.exec_amount, p.cname as plan_name, d.cname as dept_name from Snap_plan_check s left join v_plan p on s.plan_code p.plan_code left join org_office d on s.plan_dept d.off_org_code group by s.plan_code, s.plan_dept, s.plan_amount, s.exec_amount, p.cname, d.cname; end Fsp_Plan_CheckPrj;mapper XML 的關(guān)鍵在于resultMap把列別名映射到 Java 屬性select標(biāo)簽聲明statementTypeCALLABLEOUT參數(shù)寫(xiě)modeOUT jdbcTypeCURSOR resultMapcursorMapresultMap typejava.util.HashMap idcursorMap result columnplan_code propertyplan_code/ result columnplan_dept propertyplan_dept/ result columnplan_amount propertyplan_amount/ result columnexec_amount propertyexec_amount/ result columnplan_name propertyplan_name/ result columndept_name propertydept_name/ /resultMap select idcall_Fsp_Plan_CheckPrj parameterTypemap statementTypeCALLABLE {call Fsp_Plan_CheckPrj( #{grantNo, jdbcTypeVARCHAR, modeIN}, #{offOrgCode, jdbcTypeINTEGER, modeIN}, #{v_cursor, modeOUT, jdbcTypeCURSOR, resultMapcursorMap} )} /select這里有幾個(gè)容易寫(xiě)錯(cuò)的點(diǎn)。jdbcTypeCURSOR是 Oracle 驅(qū)動(dòng)識(shí)別的類型MySQL 沒(méi)有這個(gè)類型后面會(huì)單獨(dú)說(shuō)。resultMap的type用java.util.HashMap是為了讓列名直接作為 key如果你有實(shí)體類換成實(shí)體類全限定名也行但屬性名要和property對(duì)上。modeIN的兩個(gè)參數(shù)必須顯式寫(xiě)jdbcType否則某些驅(qū)動(dòng)版本會(huì)報(bào)Invalid column type。3.2 Java 調(diào)用代碼Java 側(cè)把OUT參數(shù)先塞一個(gè)空ArrayList占位調(diào)用后 MyBatis 會(huì)把游標(biāo)結(jié)果填回來(lái)MapString, Object params new HashMap(); GrantSetting gs this.grantSettingDao.get(grantCode); params.put(grantNo, StringUtils.substring(gs.getGrantNo(), 0, 2)); params.put(offOrgCode, SecurityUtils.getPersonOffOrgCode()); params.put(v_cursor, new ArrayListMapString, Object()); this.batisDao.getSearchList(call_Fsp_Plan_CheckPrj, params); ListMapString, Object rows (ListMapString, Object) params.get(v_cursor); for (MapString, Object row : rows) { System.out.println(row.get(plan_code) / row.get(plan_name)); }getSearchList是你 DAO 里封裝的方法內(nèi)部走sqlSession.selectList(call_Fsp_Plan_CheckPrj, params)。調(diào)用完成后params.get(v_cursor)就是可遍歷的List每個(gè)元素是一行key 是resultMap里的property名。實(shí)測(cè)下來(lái)這個(gè) List 是懶加載的如果你在sqlSession關(guān)閉后才去遍歷可能拿不到數(shù)據(jù)所以遍歷動(dòng)作要放在同一個(gè)會(huì)話內(nèi)。3.3 MySQL 場(chǎng)景的差異MySQL 存儲(chǔ)過(guò)程沒(méi)有sys_refcursor這種原生游標(biāo)類型通常用兩種替代一是存儲(chǔ)過(guò)程直接select結(jié)果集MyBatis 用普通select接二是用OUT參數(shù)返回結(jié)果集但驅(qū)動(dòng)支持有限。如果你從 Oracle 遷過(guò)來(lái)最穩(wěn)的做法是把OUT游標(biāo)改成存儲(chǔ)過(guò)程內(nèi)的selectmapper 里去掉CALLABLE按普通查詢寫(xiě)。下面是一個(gè) MySQL 的等價(jià)寫(xiě)法delimiter // create procedure Fsp_Plan_CheckPrj( in v_grantno varchar(32), in v_deptcode int ) begin select s.plan_code, s.plan_dept, s.plan_amount, s.exec_amount, p.cname as plan_name, d.cname as dept_name from Snap_plan_check s left join v_plan p on s.plan_code p.plan_code left join org_office d on s.plan_dept d.off_org_code group by s.plan_code, s.plan_dept, s.plan_amount, s.exec_amount, p.cname, d.cname; end // delimiter ;對(duì)應(yīng)的 mapper 就是普通selectstatementType用默認(rèn)的PREPARED不需要resultMap里的游標(biāo)映射。這個(gè)差異一定要在遷移前確認(rèn)否則你會(huì)對(duì)著jdbcTypeCURSOR報(bào)錯(cuò)半天最后發(fā)現(xiàn)是數(shù)據(jù)庫(kù)類型不對(duì)。4. 驗(yàn)證請(qǐng)求與成功結(jié)果配置寫(xiě)完后先別急著上業(yè)務(wù)代碼用最小驗(yàn)證跑一遍。第一步確認(rèn) TaoToken 的 Key 有效在模型對(duì)話入口發(fā)一條測(cè)試消息能正常返回就說(shuō)明憑證沒(méi)問(wèn)題。第二步驗(yàn)證數(shù)據(jù)庫(kù)連接和存儲(chǔ)過(guò)程本身用 SQL 客戶端直接call Fsp_Plan_CheckPrj(01, 1001, ?)看游標(biāo)能不能出數(shù)據(jù)。第三步才是走 MyBatis。MyBatis 側(cè)的驗(yàn)證建議寫(xiě)一個(gè)獨(dú)立的單元測(cè)試不要混在業(yè)務(wù)鏈路里Test public void testCallProcedure() { MapString, Object params new HashMap(); params.put(grantNo, 01); params.put(offOrgCode, 1001); params.put(v_cursor, new ArrayListMapString, Object()); sqlSession.selectList(call_Fsp_Plan_CheckPrj, params); ListMapString, Object rows (ListMapString, Object) params.get(v_cursor); assertNotNull(rows); assertTrue(rows.size() 0); System.out.println(返回行數(shù): rows.size()); System.out.println(首行: rows.get(0)); }成功的結(jié)果長(zhǎng)這樣控制臺(tái)打印出返回行數(shù)和首行內(nèi)容首行的 key 是plan_code、plan_name這些別名value 是對(duì)應(yīng)字段值。如果rows是空 List先別改代碼去 SQL 客戶端確認(rèn)存儲(chǔ)過(guò)程本身有沒(méi)有數(shù)據(jù)。如果rows是null說(shuō)明OUT參數(shù)沒(méi)被正確回填重點(diǎn)查jdbcType和resultMap是否寫(xiě)對(duì)。提示Oracle 驅(qū)動(dòng)版本不同CURSOR類型的處理方式略有差異。如果你用的是ojdbc8以上jdbcTypeCURSOR一般沒(méi)問(wèn)題老版本ojdbc14可能需要換成jdbcTypeOTHER并配合typeHandler。這個(gè)坑我在老項(xiàng)目里踩過(guò)換驅(qū)動(dòng)比改代碼省事。5. 本篇常見(jiàn)報(bào)錯(cuò)排查5.1 ORA-01000 游標(biāo)數(shù)超限報(bào)錯(cuò)信息類似ORA-01000: maximum open cursors exceeded。原因是游標(biāo)沒(méi)關(guān)閉每次調(diào)用都開(kāi)一個(gè)新的。排查方向確認(rèn)sqlSession有沒(méi)有正常關(guān)閉Spring 環(huán)境下檢查事務(wù)邊界如果用了連接池檢查連接歸還時(shí)游標(biāo)是否釋放。臨時(shí)緩解可以調(diào)大數(shù)據(jù)庫(kù)的open_cursors參數(shù)但根治還是要保證會(huì)話關(guān)閉。5.2 Invalid column type這個(gè)報(bào)錯(cuò)通常出現(xiàn)在OUT參數(shù)的jdbcType上。Oracle 的sys_refcursor必須寫(xiě)jdbcTypeCURSOR寫(xiě)成VARCHAR或OTHER都可能報(bào)錯(cuò)。另外IN參數(shù)如果沒(méi)寫(xiě)jdbcType某些驅(qū)動(dòng)也會(huì)報(bào)這個(gè)。逐個(gè)參數(shù)補(bǔ)上jdbcType基本能解決。5.3 返回結(jié)果為空 List分三種情況。一是存儲(chǔ)過(guò)程本身沒(méi)查到數(shù)據(jù)去 SQL 客戶端驗(yàn)證。二是resultMap的column和存儲(chǔ)過(guò)程里的列別名不一致比如存儲(chǔ)過(guò)程寫(xiě)plan_nameresultMap寫(xiě)planName映射不上就是空值。三是OUT參數(shù)的占位對(duì)象類型不對(duì)必須是new ArrayList()不能是null或String。5.4 TaoToken 側(cè) 401 或 404401 是 Key 無(wú)效或過(guò)期去 API Keys 頁(yè)面重新生成。404 是base_url寫(xiě)錯(cuò)確認(rèn)只寫(xiě)到https://taotoken.net/api不要帶多余路徑。如果兩個(gè)都排除了還是不通去接入文檔對(duì)照一下請(qǐng)求頭格式有些客戶端需要顯式帶Authorization: Bearer sk-xxx。5.5 MySQL 下 jdbcTypeCURSOR 報(bào)錯(cuò)MySQL 沒(méi)有CURSOR這個(gè) JDBC 類型寫(xiě)了必然報(bào)錯(cuò)。解決辦法是按 3.3 節(jié)的方案把存儲(chǔ)過(guò)程改成直接selectmapper 去掉CALLABLE和OUT游標(biāo)參數(shù)。如果你必須保留OUT參數(shù)MySQL 需要用jdbcTypeOTHER配合自定義TypeHandler復(fù)雜度高不推薦。6. 把配置沉淀下來(lái)下次直接復(fù)用整套流程跑通后建議把三樣?xùn)|西沉淀成模板mapper XML 的CALLABLE骨架、Java 側(cè)的params組裝代碼、TaoToken 的settings.json/config.toml配置。下次遇到新的存儲(chǔ)過(guò)程只改存儲(chǔ)過(guò)程名、參數(shù)名和resultMap列映射其余照抄能省掉大量試錯(cuò)時(shí)間。如果你在配 TaoToken 的過(guò)程中遇到 Key 或路徑問(wèn)題直接去 API Keys 頁(yè)面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 重新生成一個(gè)再對(duì)照接入文檔 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 檢查請(qǐng)求格式。長(zhǎng)期做編碼 Agent 任務(wù)的話Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 能把額度單獨(dú)管理不會(huì)和臨時(shí)調(diào)試混在一起。模型對(duì)話調(diào)試入口在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 先用它確認(rèn) Key 有效再去配本地工具排查鏈路會(huì)清晰很多。最后留一個(gè)實(shí)用技巧存儲(chǔ)過(guò)程的resultMap列映射建議和存儲(chǔ)過(guò)程里的select別名逐字對(duì)照寫(xiě)不要憑記憶。我見(jiàn)過(guò)太多空 List 的案例最后都是plan_code和planCode這種大小寫(xiě)或下劃線差異導(dǎo)致的。把這兩處放在一起比對(duì)比任何調(diào)試工具都快。