分页的存储过程配 TaoToken:从 settings.json 骨架到可复现验证

发布时间:2026/9/29 21:05:06
分页的存储过程配 TaoToken:从 settings.json 骨架到可复现验证 1. 分页存储过程为什么总在“最后一页”翻车分页的存储过程说白了就是把「查第 N 页、每页 M 条」这件事封装进数据库让上层业务只传表名、页大小、页码三个参数就能拿到一页数据和总页数。它适合谁适合那些还在用 Oracle、SQL Server、MySQL 写业务系统又不想把分页 SQL 散落在几十个 Java 文件里的团队。核心检索词就三个存储过程、分页、结果集边界。我见过太多分页存储过程在测试环境跑得好好的一上生产就出问题。典型症状有三种第一翻到最后一页返回空结果集但总页数明明显示还有第二页码传 0 或者负数时直接抛异常调用方拿到一个没有上下文的错误第三总条数统计和实际返回的行数对不上因为统计 SQL 和分页 SQL 用的过滤条件不一致。这些问题的根子不在 SQL 写得多复杂而在于「配置」和「调用」之间缺了一层统一的约定。存储过程本身是数据库里的逻辑但它的参数从哪来、Key 怎么管、调用链怎么追踪这些工程化的问题如果只靠硬编码维护成本会指数级上升。所以这篇要做的是把分页存储过程和一个统一的配置骨架绑在一起用settings.json管住连接参数和通道信息用 TaoToken 统一 Key/API 通道让分页查询的每一次调用都可复现、可追踪。下面我会先给出一份可以直接抄的settings.json骨架再写一个带边界处理的分页存储过程然后用 Python 和 Java 两种方式调用验证最后把常见的翻车点一个个拆开。你跟着做能拿到一个「页码越界不崩、总页数准确、调用可追踪」的分页方案。2. TaoToken 前置统一 Key 与 API 通道在写存储过程之前先把「通道」这件事说清楚。分页存储过程本身不关心 Key但调用它的应用需要连数据库、需要调模型做 SQL 审核或者日志分析这些外部调用如果每个服务各管一套 Key很快就会乱。TaoToken 在这里的角色是提供一个统一的 API 通道把模型对话、编码辅助、Key 管理收敛到一个入口。你需要先拿到一个可用的 API Key。操作路径是访问官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 进入控制台在 API Keys 页面创建一个新 Key。创建时建议按用途命名比如proc-page-dev这样后面在settings.json里引用时一眼能看出是给分页存储过程调试用的。拿到 Key 之后API 的基础地址是 https://taotoken.net/api 注意这个地址不带任何查询参数直接作为 base_url 使用。如果你用的是 OpenAI 兼容的 SDK把base_url指向它api_key填刚创建的值即可。对于长期跑编码任务或者 Agent 场景可以了解 Coding Plan它更适合需要持续调用、按周期计费的用法如果只是临时验证模型输出用模型对话页面就够了。这里要强调一点TaoToken 是统一的 API 通道不是让你绕过数据库直连生产库。分页存储过程该在数据库里跑还是在数据库里跑TaoToken 负责的是调用链上的 Key 管理和请求追踪。两者职责分开后面排查问题才不会互相甩锅。3. 可复制配置settings.json 骨架与分页存储过程3.1 settings.json 骨架先给一份可以直接落地的settings.json。它的设计原则是数据库连接、TaoToken 通道、分页默认参数三块分开互不污染。你只需要替换host、service_name、user、password和api_key这几处。{ database: { type: oracle, host: 127.0.0.1, port: 1521, service_name: ORCLPDB1, user: app_user, password: your_db_password, pool: { min_size: 2, max_size: 10, timeout_seconds: 30 } }, taotoken: { base_url: https://taotoken.net/api, api_key: sk-your-taotoken-key, default_model: gpt-4o-mini, timeout_seconds: 60, max_retries: 2 }, pagination: { default_page_size: 20, max_page_size: 200, default_page_number: 1, count_timeout_seconds: 10 }, logging: { level: INFO, trace_channel_calls: true, log_sql: false } }几个参数值得单独说。pagination.max_page_size是防止调用方传一个page_size100000把数据库拖垮存储过程里会做二次校验。logging.trace_channel_calls打开后每次通过 TaoToken 发起的请求都会带一个 trace id方便和数据库侧的调用日志对齐。database.pool里的timeout_seconds要和存储过程的执行时间匹配分页查询如果超过 30 秒还没返回大概率是统计 SQL 没走索引。3.2 分页存储过程Oracle 版下面这个存储过程在原始 excerpt 的基础上做了三处工程化改造参数校验、总页数计算与结果集边界对齐、异常时返回明确错误码。先创建包和游标类型CREATE OR REPLACE PACKAGE pkg_page AS TYPE page_cursor IS REF CURSOR; END pkg_page; / CREATE OR REPLACE PROCEDURE proc_page ( p_table_name IN VARCHAR2, p_page_size IN NUMBER, p_page_number IN NUMBER, p_total_rows OUT NUMBER, p_total_pages OUT NUMBER, p_result OUT pkg_page.page_cursor, p_error_code OUT NUMBER, p_error_msg OUT VARCHAR2 ) AS v_sql VARCHAR2(4000); v_count_sql VARCHAR2(1000); v_offset NUMBER; v_page_size NUMBER; v_page_num NUMBER; BEGIN p_error_code : 0; p_error_msg : NULL; -- 参数校验页大小和页码都必须是正整数 IF p_page_size IS NULL OR p_page_size 0 THEN p_error_code : 1001; p_error_msg : page_size must be a positive integer; RETURN; END IF; IF p_page_number IS NULL OR p_page_number 0 THEN p_error_code : 1002; p_error_msg : page_number must be a positive integer; RETURN; END IF; v_page_size : LEAST(p_page_size, 200); v_page_num : p_page_number; v_offset : (v_page_num - 1) * v_page_size; -- 统计总行数 v_count_sql : SELECT COUNT(*) FROM || DBMS_ASSERT.SIMPLE_SQL_NAME(p_table_name); EXECUTE IMMEDIATE v_count_sql INTO p_total_rows; -- 计算总页数注意向上取整 p_total_pages : CEIL(p_total_rows / v_page_size); -- 页码超出总页数时返回空结果集而不是报错 IF p_total_rows 0 OR v_page_num p_total_pages THEN OPEN p_result FOR SELECT * FROM DUAL WHERE 1 0; RETURN; END IF; -- 分页查询用 ROWNUM 两层嵌套外层过滤 rn v_sql : SELECT * FROM ( || SELECT t.*, ROWNUM rn FROM ( || SELECT * FROM || DBMS_ASSERT.SIMPLE_SQL_NAME(p_table_name) || ) t WHERE ROWNUM || (v_offset v_page_size) || ) WHERE rn || v_offset; OPEN p_result FOR v_sql; EXCEPTION WHEN OTHERS THEN p_error_code : SQLCODE; p_error_msg : SUBSTR(SQLERRM, 1, 500); IF p_result%ISOPEN THEN CLOSE p_result; END IF; END proc_page; /这里有几个关键点。第一DBMS_ASSERT.SIMPLE_SQL_NAME用来防止表名拼接注入虽然存储过程内部调用但表名来自外部参数必须校验。第二页码超出总页数时OPEN p_result FOR SELECT * FROM DUAL WHERE 1 0返回一个空结果集调用方拿到的是「0 行」而不是异常这样前端翻到最后一页再点下一页不会崩。第三p_total_pages用CEIL计算总行数 21、页大小 20 时结果是 2不会出现「第 2 页是空的但总页数显示 1」这种矛盾。3.3 调用示例Python 侧用 Python 的oracledb库调用同时读取settings.json里的配置。注意cursor.callproc的参数顺序要和存储过程定义一致。import json import oracledb with open(settings.json, r, encodingutf-8) as f: cfg json.load(f) db cfg[database] conn oracledb.connect( userdb[user], passworddb[password], dsnf{db[host]}:{db[port]}/{db[service_name]} ) page_size cfg[pagination][default_page_size] page_number cfg[pagination][default_page_number] cursor conn.cursor() total_rows cursor.var(int) total_pages cursor.var(int) result_cursor cursor.var(oracledb.CURSOR) error_code cursor.var(int) error_msg cursor.var(str) cursor.callproc( proc_page, [ USERS, page_size, page_number, total_rows, total_pages, result_cursor, error_code, error_msg, ], ) if error_code.getvalue() ! 0: print(fprocedure error: {error_code.getvalue()} - {error_msg.getvalue()}) else: print(ftotal_rows{total_rows.getvalue()}, total_pages{total_pages.getvalue()}) rows result_cursor.getvalue().fetchall() for row in rows: print(row) conn.close()跑通之后你会看到类似total_rows105, total_pages6的输出以及第一页的 20 行数据。把page_number改成 6返回最后 5 行改成 7返回空列表且error_code0。这就是边界处理生效的表现。4. 验证请求与成功结果4.1 分页结果正确性验证验证分页是否正确不能只看第一页。我通常用三个动作交叉确认。第一个动作把page_size设为 10依次请求第 1 页到第 6 页把每页返回的行数加起来应该等于total_rows。第二个动作请求第total_pages 1页确认返回 0 行且error_code0。第三个动作把page_size设为 0 或负数确认返回error_code1001而不是数据库抛出的原始异常。# 验证逐页累加行数 all_rows 0 for p in range(1, total_pages.getvalue() 1): cursor.callproc(proc_page, [USERS, 10, p, total_rows, total_pages, result_cursor, error_code, error_msg]) rows result_cursor.getvalue().fetchall() all_rows len(rows) print(fpage {p}: {len(rows)} rows) print(fsum{all_rows}, expected{total_rows.getvalue()}) assert all_rows total_rows.getvalue(), row count mismatch如果sum和expected对不上八成是统计 SQL 和分页 SQL 的过滤条件不一致或者表在两次查询之间被写入了新数据。生产环境建议在同一个事务快照里做统计和分页或者接受「总行数是一个近似值」并在文档里说明。4.2 通道调用可追踪验证TaoToken 侧的追踪靠的是每次请求带上的 trace id。在settings.json里打开trace_channel_calls后你可以在调用模型做 SQL 审核时把存储过程的参数一起传进去这样日志里能同时看到「哪次分页调用触发了哪次模型请求」。import requests def review_sql_with_taotoken(sql_text, trace_id): headers { Authorization: fBearer {cfg[taotoken][api_key]}, Content-Type: application/json, X-Trace-Id: trace_id, } payload { model: cfg[taotoken][default_model], messages: [ {role: system, content: 你是一个 SQL 审核助手只回答风险点。}, {role: user, content: f检查这段分页 SQL 是否有注入风险{sql_text}}, ], } resp requests.post( f{cfg[taotoken][base_url]}/v1/chat/completions, headersheaders, jsonpayload, timeoutcfg[taotoken][timeout_seconds], ) resp.raise_for_status() return resp.json() trace_id fproc-page-{page_number}-{page_size} result review_sql_with_taotoken(SELECT * FROM USERS WHERE ROWNUM 20, trace_id) print(result[choices][0][message][content])成功的结果是模型返回一段简短的风险说明同时你在 TaoToken 控制台的请求日志里能按X-Trace-Id搜到这次调用。这样分页存储过程的每一次执行都能和通道调用对齐出问题时不用在两个系统之间来回猜。5. 本篇常见错排查5.1 ORA-00942 表或视图不存在这个错误通常不是表真的不存在而是p_table_name传进来时带了 schema 前缀或者大小写不对。Oracle 默认把未加引号的标识符转成大写如果你传的是users存储过程里拼出来的是users而实际表名是USERS就会报 ORA-00942。解决办法是在settings.json里约定表名统一大写或者在存储过程里加UPPER(p_table_name)。但更稳妥的做法是调用方传准确的表名存储过程只做DBMS_ASSERT校验不做隐式转换。5.2 最后一页返回空但总页数不为零这是最典型的分页边界问题。原因通常是分页 SQL 的rn offset和ROWNUM offset page_size两个条件在最后一页时交集为空。检查你的v_offset计算(page_number - 1) * page_size。如果page_number从 0 开始传第一页的 offset 就是负数整个 SQL 逻辑就乱了。确认调用方传的页码从 1 开始存储过程里也做了p_page_number 0的校验。5.3 总页数比实际能翻的页数多 1比如 100 行、每页 10 条总页数应该是 10但返回了 11。这通常是CEIL用在了错误的地方或者统计 SQL 把COUNT(*)写成了COUNT(1)但过滤条件多了一个WHERE 11之外的冗余条件。检查p_total_pages : CEIL(p_total_rows / v_page_size)这一行确保p_total_rows是准确的过滤后行数。如果表里有软删除标记统计和分页都要带上同样的WHERE is_deleted 0。5.4 TaoToken 请求返回 401401 说明 Key 无效或者请求头格式不对。先确认settings.json里的api_key是完整的没有多余空格。然后确认Authorization头的格式是Bearer sk-xxx注意Bearer和 Key 之间有一个空格。如果 Key 是在控制台刚创建的确认它没有被禁用或者过期。另外base_url不要写成https://taotoken.net/api/带尾部斜杠拼接/v1/chat/completions时会出现双斜杠部分网关会拒绝。5.5 存储过程编译通过但调用时报参数个数不匹配Oracle 的callproc对参数顺序和类型很敏感。OUT参数必须用cursor.var()声明不能直接传 Python 的None。如果你在 Python 里传了 7 个参数但存储过程定义了 8 个会报ORA-06550。对照存储过程的参数列表逐个核对特别是p_result这个游标类型必须用oracledb.CURSOR声明。6. 把分页存储过程接进你的工程链路到这里你已经有了一个可复制的settings.json骨架、一个带边界处理的分页存储过程、两种语言的调用示例以及一套验证动作。接下来要做的是把它接进你现有的工程链路。如果你主要是在做数据库侧的排障和接入建议先去 API Keys 页面确认 Key 的权限范围然后对照接入文档把base_url和鉴权头配置到你的 HTTP 客户端里。如果你需要验证模型对分页 SQL 的审核效果直接用模型对话页面贴一段 SQL 进去看它能不能指出ROWNUM嵌套的边界问题。如果你是长期跑编码任务或者 Agent需要按周期稳定调用Coding Plan 会比按次调用更省心。最后留一个我踩过的坑分页存储过程的p_total_rows和p_total_pages在并发写入场景下会漂移。如果你的业务对总页数要求绝对准确要么在统计和分页之间加表锁要么接受「总页数仅供参考」并在前端做容错。分页的核心不是把数字算得完美而是让调用方在任何边界下都能拿到一个可预期的结果。