先把一条带额度校验的 AI 请求跑通——MkSaaS 的最小链路拆开看

文章导读
一条带额度校验的 AI 请求,在 MkSaaS 这类框架里通常按这个顺序走:路由入口 → 中间件(鉴权、额度判断)→ 控制器组装参数 → 调用模型 → 拿到响应 → 扣减额度并写调用记录。关键判断是:额度校验放在模型调用之前,扣减放在响应回来之后,这两个动作分属不同层,不要合成一个步骤做。
📋 目录
  1. A 从路由入口打印一条请求的完整经过顺序
  2. B 在进入模型调用之前完成额度读取与判断
  3. C 调用时只传必要参数并完整保留响应
  4. D 在返回之后扣减额度并写入调用记录
  5. E 用一条正常请求和一条超额请求对照验证
A A

一条带额度校验的 AI 请求,在 MkSaaS 这类框架里通常按这个顺序走:路由入口 → 中间件(鉴权、额度判断)→ 控制器组装参数 → 调用模型 → 拿到响应 → 扣减额度并写调用记录。关键判断是:额度校验放在模型调用之前,扣减放在响应回来之后,这两个动作分属不同层,不要合成一个步骤做。

先按「调用前只读判断、调用后按实际用量扣减」把链路跑通,再用日志和调用记录表对照验证。额度字段放用户表还是独立额度表,取决于你的记账粒度;最小链路可以先只判断余额、不做并发预留,但这只适用于低并发或单机验证场景,正式对外前需要结合环境确认并发扣减是否会造成透支。

从路由入口打印一条请求的完整经过顺序

先拿到框架默认的中间件和实际执行顺序,后面的挂载点才有依据。多数框架区分全局中间件与路由级中间件,请求阶段按注册数组正序执行,响应阶段逆序返回;这条规则需要结合你使用的版本确认,不要凭印象假设。

  • 路由定义位置:找到 /api/ai/chat 这类入口,记下它绑定了哪些中间件、对应哪个控制器方法。
  • 中间件注册顺序:全局中间件一般在启动文件或内核文件里注册,鉴权与额度校验这类业务中间件建议挂在路由分组上。
  • 每层打印的日志字段:request_id、user_id、layer、path、method、quota_before、model、status、ts。

request_id 由入口生成并一路透传,否则四层日志对不上。最小可用的打点骨架:

// 中间件 handle,请求进入阶段
log.info('mw_in', {
  request_id: ctx.requestId,
  user_id: ctx.userId,
  path: req.path,
  method: req.method,
  quota_before: user && user.quota_balance,
  ts: Date.now(),
});
return next();

打印完这条日志,再发一次请求,你应该能在输出里看到入口、鉴权、额度校验、控制器依次出现。顺序对了,再往下谈扣减才有意义。

在进入模型调用之前完成额度读取与判断

额度字段通常有两种放法:直接放用户表(quota_balance、credit),或放独立额度表(user_id、quota_total、quota_used)。最小链路用前者更快;如果后续要按周期重置,或要区分赠送额度与充值额度,独立表更合适。

挂载点建议放在鉴权之后、控制器之前。鉴权先确定 user_id,额度判断才知道查谁的余额。通用判断骨架:

先把一条带额度校验的 AI 请求跑通——MkSaaS 的最小链路拆开看
const user = await userRepo.findById(ctx.userId);
const minNeeded = estimateMinCost(req.body); // 最小可调用额度,可先取固定占位值

if (!user || user.quota_balance < minNeeded) {
  log.warn('quota_rejected', {
    request_id: ctx.requestId,
    user_id: ctx.userId,
    balance: user && user.quota_balance,
  });
  return res.status(402).json({
    code: 'INSUFFICIENT_QUOTA',
    message: '额度不足',
  });
}

ctx.quotaBefore = user.quota_balance;
return next();

拒绝请求时用明确的 HTTP 状态码加业务码,例如 402 或 429 配 INSUFFICIENT_QUOTA。不建议用 200 包一个 error 字段,前端判断和日志排查都会更麻烦。

需要留意的边界:只读判断不做预留时,同一用户并发发多条请求可能同时通过,扣减阶段被扣成负数。要避免透支,可以在扣减时加条件更新(update ... where quota_balance >= cost),或在调用前做一次额度预占。

调用时只传必要参数并完整保留响应

传给模型的参数建议从请求体按白名单取:model、messages、temperature、max_tokens。密钥、供应商地址、用户身份这类由服务端补齐,不要从请求体透传——请求体是外部输入,白名单能减少被改写的面。

响应至少要保留:返回 id、model、choices[].message、finish_reason,以及 usage 里的 prompt_tokens、completion_tokens、total_tokens。usage 是后续扣减的依据,不落库也至少要落日志,否则扣减只能靠估算。

const payload = {
  model: req.body.model,
  messages: req.body.messages,
  temperature: req.body.temperature,
  max_tokens: req.body.max_tokens,
};

let result = null;
let error = null;
try {
  result = await aiClient.chat(payload, { timeout: 30000 });
} catch (e) {
  error = e;
}

log.info('model_call_done', {
  request_id: ctx.requestId,
  ok: !error,
  error_code: error && error.code,
  usage: result && result.usage,
});
ctx.rawResponse = result; // 原样保留,供扣减与排查使用

调用要设超时,用 try/catch 捕获,记录 error.code 与 error.message,不要把上游异常原文返回给前端。超时后是否计费取决于供应商口径,最小链路可以先按「拿不到 usage 就不扣」处理,正式计费口径需要结合供应商账单核对。

在返回之后扣减额度并写入调用记录

扣减要和调用结果绑定:成功分支按 usage 计算并扣减;失败分支先不扣,同时写一条 status=failed 的记录,这样对账时能区分「没调用」和「调用失败」。

先把一条带额度校验的 AI 请求跑通——MkSaaS 的最小链路拆开看
const tokens = ctx.rawResponse && ctx.rawResponse.usage
  ? ctx.rawResponse.usage.total_tokens : 0;
const cost = calcCost(tokens); // 按你的计价规则,先可用固定系数占位

if (error || tokens === 0) {
  await callLog.insert({
    request_id: ctx.requestId, user_id: ctx.userId,
    model: ctx.model, status: 'failed',
    quota_cost: 0, error_code: error ? error.code : 'NO_USAGE',
  });
} else {
  await db.transaction(async (tx) => {
    await tx.user.decrement('quota_balance', { by: cost });
    await callLog.insert({
      request_id: ctx.requestId, user_id: ctx.userId,
      model: ctx.model, status: 'success',
      prompt_tokens: ctx.rawResponse.usage.prompt_tokens,
      completion_tokens: ctx.rawResponse.usage.completion_tokens,
      total_tokens: tokens, quota_cost: cost,
    });
  });
}

调用记录表建议字段:request_id、user_id、model、prompt_tokens、completion_tokens、quota_cost、status、error_code、created_at。request_id 上加唯一索引,重复写入用 insertOrIgnore 或 ON DUPLICATE KEY UPDATE 处理,重试时才不会重复扣:

ALTER TABLE ai_call_logs
  ADD UNIQUE KEY uk_request_id (request_id);

用一条正常请求和一条超额请求对照验证

正常请求可以先用一条最小调用验证:

curl -s -X POST http://127.0.0.1:8000/api/ai/chat \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <TOKEN>' \
  -d '{"model":"<MODEL>","messages":[{"role":"user","content":"ping"}]}'

日志里应当依次出现 mw_in、auth_ok、quota_ok、model_call_done、扣减完成;数据库里用户余额减少,ai_call_logs 多出一条 status=success 的记录,且 request_id 与日志一致。

再把该用户余额调到低于最低消耗,发同样的请求。日志应当停在 quota_rejected,不出现 model_call_done——这说明超额是在中间件层被拦下的,位置在模型调用之前,是最省成本的一层。数据库方面余额不变,ai_call_logs 不新增记录。

SELECT request_id, user_id, model, status, quota_cost, total_tokens, created_at
FROM ai_call_logs ORDER BY id DESC LIMIT 5;

SELECT id, quota_balance FROM users WHERE id = <USER_ID>;

两个容易漏掉的检查:用同一个 request_id 重放一次调用,验证唯一索引是否真的挡住了重复扣减;并发发两条请求,观察余额是否出现负数,出现负数说明判断与扣减之间缺少条件更新或预占。