feat: 新增扣费预检请求

This commit is contained in:
x6g2
2026-11-07 10:02:44 +08:00
parent 4ac6aed669
commit 14770bef18
20 changed files with 476 additions and 40 deletions
+2 -2
View File
@@ -271,7 +271,7 @@ export const faqs: Faq[] = [
},
{
q: '调用失败会扣费吗?',
a: '不会。只有上游返回成功(2xx)时才会触发扣费;鉴权失败、限流、上游异常等情况都不计入账单。每次调用都可以在「调用日志」中查看是否计费以及对应金额。'
a: '不会。网关会在调用上游前先校验余额、次数包与免费额度,不足时直接返回 40201,不调用上游也不计入账单;只有上游返回成功(2xx)时才会触发扣费。每次调用都可以在「调用日志」中查看是否计费以及对应金额。'
},
{
q: '有现成的 SDK 吗?',
@@ -283,7 +283,7 @@ export const faqs: Faq[] = [
},
{
q: '常见错误码如何排查?',
a: '40101 表示 AppKey 无效,40102 为时间戳超出允许偏移,40103 为 Nonce 已被使用,40104 为签名校验失败,40304 表示应用未开通该接口,42901 表示触发 QPS 限制。响应中的 requestId 可用于在调用日志中定位完整链路。'
a: '40101 表示 AppKey 无效,40102 为时间戳超出允许偏移,40103 为 Nonce 已被使用,40104 为签名校验失败,40201 表示账户余额或可用次数不足(请先充值或购买次数包),40304 表示应用未开通该接口,42901 表示触发 QPS 限制。响应中的 requestId 可用于在调用日志中定位完整链路。'
}
];
@@ -1,5 +1,6 @@
package com.starsee.starapi.billing.api;
import com.starsee.starapi.billing.api.domain.DeductPrecheckDTO;
import com.starsee.starapi.billing.api.domain.DeductRequestDTO;
import com.starsee.starapi.billing.api.domain.DeductResultDTO;
import com.starsee.starapi.billing.api.domain.WalletDTO;
@@ -20,6 +21,15 @@ public interface BillingService {
*/
DeductResultDTO deduct(DeductRequestDTO request);
/**
* 扣费预检:按「免费试用额度 → 次数包 → 钱包余额」优先级只读判断本次调用是否存在可用的付费来源,
* 供网关在转发上游前拒绝余额或额度不足的调用;不扣减任何额度、不写资金流水。
*
* @param request 扣费请求
* @return 预检结果,{@code payable=false} 时 {@code reason} 为拒绝原因
*/
DeductPrecheckDTO precheck(DeductRequestDTO request);
/**
* 查询开发者钱包资产
*
@@ -0,0 +1,48 @@
package com.starsee.starapi.billing.api.domain;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 计费预检结果,由网关在转发上游前调用 admin 内网预检接口获取
* <p>
* 预检按「免费试用额度 → 次数包 → 钱包余额」优先级判断本次调用是否存在可用的付费来源,
* 只读不落库;网关据此在转发前拒绝余额或额度不足的调用,避免先调用上游再扣费失败。
*
* @author x6g2
*/
@Data
public class DeductPrecheckDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 本次调用是否可继续(true=可支付或免计费,false=无可用付费来源,应拒绝本次调用)
*/
private Boolean payable;
/**
* 预计扣费模式(0无 1扣免费额度 2扣次数包 3扣钱包余额)
*/
private String chargeMode;
/**
* 预计扣减金额(元),免计费或扣减次数包时为 0
*/
private BigDecimal estimatedAmount;
/**
* 钱包当前可用余额(元),非钱包扣费时可能为 {@code null}
*/
private BigDecimal balance;
/**
* 判定说明,不可支付时为拒绝原因(如「钱包余额不足,请先充值」)
*/
private String reason;
}
@@ -30,6 +30,11 @@ public interface InternalApiConstants {
*/
String BILLING_DEDUCT_PATH = "/internal/billing/deduct";
/**
* 计费预检内网接口路径
*/
String BILLING_PRECHECK_PATH = "/internal/billing/precheck";
/**
* 调用日志写入内网接口路径
*/
@@ -62,6 +62,15 @@ public interface AppAuthService {
*/
boolean isAuthorized(Long appId, Long apiId);
/**
* 判断应用在指定接口上是否还有剩余免费试用次数,供计费预检只读判断
*
* @param appId 应用ID
* @param apiId 接口ID
* @return true=授权已开通且剩余免费次数大于 0,false=无可用免费次数
*/
boolean hasFreeCall(Long appId, Long apiId);
/**
* 原子扣减一次应用在指定接口上的免费试用次数,供计费引擎按「免费额度」优先扣费
*
+20 -11
View File
@@ -20,19 +20,21 @@
│ 6. 接口授权(openapi:app:auth:{appId}) │
│ 7. 请求ID幂等(回放 or 拒绝) │
│ 8. QPS 令牌桶 + 应用日调用限额 │
│ 9. 转发上游(api_info.upstream_url) │
│ 10. 上游成功(2xx)后扣费 → 回写响应(含余额头) │
│ 11. 异步写调用日志 │
│ 9. 计费预检(免费额度/次数包/钱包余额) │
│ 10. 转发上游(api_info.upstream_url) │
│ 11. 上游成功(2xx)后扣费 → 回写响应(含余额头) │
│ 12. 异步写调用日志 │
└───────┬──────────────────────────┬────────────┘
│ Redis 只读缓存 │ 内网 HTTP(X-Internal-Token)
▼ ▼
openapi 发布的缓存 starapi-admin (8080)
/internal/billing/precheck
/internal/billing/deduct
/internal/calllog/record
```
- 入口路径:`gateway.include-paths`(默认仅 `/api/v1/**`)匹配的请求才进入网关流程,其余请求直接交给后续过滤器与 MVC。
- 过滤器链:链路按阶段拆成 8 个 `OncePerRequestFilter`,由类上的 `@Order` 注解排序(顺序常量见 `GatewayConstants.FILTER_ORDER_*`,彼此间隔 10),任一阶段失败即写出统一错误响应并结束链路:
- 过滤器链:链路按阶段拆成 9 个 `OncePerRequestFilter`,由类上的 `@Order` 注解排序(顺序常量见 `GatewayConstants.FILTER_ORDER_*`,以 10 为步长,计费预检插入在 165 位于限流与转发之间),任一阶段失败即写出统一错误响应并结束链路:
| 顺序 | 过滤器 | 职责 |
|---|---|---|
@@ -43,6 +45,7 @@
| 140 | `OpenApiAuthFilter` | 应用对接口的授权校验 |
| 150 | `OpenApiIdempotencyFilter` | 请求ID幂等:命中缓存回放、并发请求抢占处理锁 |
| 160 | `OpenApiRateLimitFilter` | QPS 令牌桶、应用日调用限额 |
| 165 | `OpenApiBillingFilter` | 转发前扣费预检:余额/次数包/免费额度均不可用时直接返回 `40201`,不调用上游 |
| 170 | `OpenApiForwardFilter` | 转发上游、2xx 后扣费、回写响应(含账户余额头)、缓存成功响应 |
- 公共逻辑(路径判定、上下文读取、统一错误响应、异常信息整理)收敛在 `AbstractOpenApiFilter`,各阶段过滤器只实现自身校验。
@@ -79,9 +82,9 @@
|---|---|---|
| `X-Gateway-Balance` | 上游返回 2xx 且扣费结果带账户余额 | 本次调用扣费后的账户余额(单位:元,保留 4 位小数)。免费额度、次数包、钱包余额三种扣费方式均返回;扣费失败、上游非 2xx 或开发者未开户时不返回 |
| `X-Gateway-Replay` | 命中请求ID幂等回放 | 固定为 `true`,表示本次未重复调用上游、未重复扣费;此时 `X-Gateway-Balance` 为首次调用扣费后的余额快照 |
| `X-Gateway-Warning` | 扣费失败 | 固定为 `charge-failed`,上游结果照常返回,但本次无法回写余额 |
| `X-Gateway-Warning` | 预检通过但扣费仍失败(并发消耗、admin 内网接口异常等) | 固定为 `charge-failed`,上游结果照常返回,但本次无法回写余额;余额/额度不足的调用已在转发前返回 `40201`,不会走到这里 |
> 账户余额取自扣费接口返回的 `balanceAfter`,网关不额外查询钱包,因此不增加调用链路开销;浏览器跨域调用时需服务端通过 `Access-Control-Expose-Headers` 放行上述头才能被 JS 读取。
> 账户余额取自扣费接口返回的 `balanceAfter`,网关不额外查询钱包;转发前预检本身只读,不改变余额。浏览器跨域调用时需服务端通过 `Access-Control-Expose-Headers` 放行上述头才能被 JS 读取。
## 三、签名算法
@@ -193,6 +196,7 @@ String signature = hmac.digestHex(signContent); // 小写十六进制
| 40102 | 401 | `TIMESTAMP_INVALID` | 请求时间戳无效或已超出允许的时间偏移 |
| 40103 | 401 | `NONCE_REPLAYED` | 请求nonce已被使用,请勿重复提交 |
| 40104 | 401 | `SIGNATURE_INVALID` | 请求签名校验未通过(缺参数时提示“请求缺少签名参数”) |
| 40201 | 402 | `BILLING_NOT_PAYABLE` | 账户余额或可用次数不足,转发前拒绝;`msg` 为具体原因(余额不足/仅支持套餐包/钱包冻结等) |
| 40301 | 403 | `APP_DISABLED` | 应用已被禁用 |
| 40302 | 403 | `USER_DISABLED` | 开发者账号已被停用或冻结 |
| 40303 | 403 | `IP_NOT_ALLOWED` | 调用来源IP不在应用白名单内 |
@@ -206,7 +210,7 @@ String signature = hmac.digestHex(signContent); // 小写十六进制
| 50301 | 503 | `API_MAINTAIN` | 开放接口维护中,请稍后重试 |
| 50401 | 504 | `UPSTREAM_TIMEOUT` | 上游服务响应超时 |
共 16 个业务码,全部来自 `GatewayErrorCode`。按 HTTP 状态可做通用重试判断,按业务码可精确定位原因。
共 17 个业务码,全部来自 `GatewayErrorCode`。按 HTTP 状态可做通用重试判断,按业务码可精确定位原因。
## 五、配置说明
@@ -226,11 +230,13 @@ String signature = hmac.digestHex(signContent); // 小写十六进制
| `gateway.idempotent.lock-ttl-seconds` | `60` | “处理中”锁时长(秒) |
| `gateway.idempotent.response-ttl-seconds` | `600` | 成功响应回放缓存时长(秒) |
| `gateway.idempotent.max-response-cache-bytes` | `262144` | 可回放响应体上限(256KB),超出只去重不回放 |
| `gateway.billing.precheck-enabled` | `true` | 转发前扣费预检开关;关闭后恢复“先转发、后扣费、扣费失败仅告警”的旧行为 |
| `gateway.internal.admin-base-url` | `http://127.0.0.1:8080` | admin 内网访问地址 |
| `gateway.internal.token` | 空(`application.yml` 取 `${INTERNAL_API_TOKEN:starapi-internal-token}`) | 内网令牌,必须与 admin 的 `internal.api.token` 一致 |
| `gateway.internal.connect-timeout-ms` | `2000` | 内网接口连接超时(毫秒) |
| `gateway.internal.read-timeout-ms` | `5000` | 内网接口读超时(毫秒) |
| `gateway.internal.deduct-path` | `/internal/billing/deduct` | 扣费接口路径(取 `InternalApiConstants`) |
| `gateway.internal.precheck-path` | `/internal/billing/precheck` | 预检接口路径(取 `InternalApiConstants`) |
| `gateway.internal.call-log-path` | `/internal/calllog/record` | 调用日志接口路径(取 `InternalApiConstants`) |
| `gateway.internal.call-log-pool-size` | `2` | 调用日志异步线程数(最大线程数不小于 4) |
| `gateway.internal.call-log-queue-capacity` | `1024` | 调用日志异步队列容量,队列满由调用线程兜底执行 |
@@ -268,13 +274,14 @@ String signature = hmac.digestHex(signContent); // 小写十六进制
## 七、计费与调用日志
- **转发前预检**:限流之后、转发上游之前调用 admin `POST /internal/billing/precheck`,同样携带 `appId`、`userId`、`apiId`、`chargeType`、`pricePerCall`,由 admin 按「免费试用额度 → 次数包 → 钱包余额」只读判断本次调用是否存在可用的付费来源。判定不可支付时网关直接返回 `40201`(`msg` 为具体原因),**不调用上游、不产生任何调用成本**;免费接口与单价不大于 0 的按次接口不会扣钱包,跳过预检;预检接口不可用时降级继续转发(不再拦截),由扣费阶段兜底告警。可通过 `gateway.billing.precheck-enabled=false` 整体关闭。
- **扣费时机**:上游返回 **2xx** 时,在回写调用方响应之前调用 admin `POST /internal/billing/deduct`,携带 `appId`、`userId`、`apiId`、`chargeType`、`pricePerCall`,并以 `requestId` 作为幂等键;admin 侧同一 `requestId` 不会重复扣费。
- **扣费失败不影响响应**:内网调用异常只记录 error 日志、写入 `error_msg`,并在响应头追加 `X-Gateway-Warning: charge-failed`,已拿到的上游结果照常返回;上游非 2xx 时不做扣费。
- **扣费失败不影响响应**:预检通过(或预检被关闭、预检接口降级)后仍可能因并发消耗、admin 内网接口短暂异常导致扣费失败,内网调用异常只记录 error 日志、写入 `error_msg`,并在响应头追加 `X-Gateway-Warning: charge-failed`,已拿到的上游结果照常返回;上游非 2xx 时不做扣费。
- **返回账户余额**:上游 2xx 且扣费结果带余额时,网关在响应头回写 `X-Gateway-Balance`(单位元、4 位小数),数据直接取自扣费接口返回的 `balanceAfter`,不额外查询钱包;扣费失败、上游非 2xx 或未开户时不返回该头。
- **调用日志**:请求结束后异步调用 `POST /internal/calllog/record`(线程池 `gatewayCallLogExecutor`,`@Async`),记录 `requestId`、`appId`、`userId`、`apiId`、请求/响应时间、耗时、HTTP 状态、上游业务码 `code`、客户端 IP、是否计费、扣费方式/次数/金额、余额、错误信息;缺路由或凭证信息时跳过(admin 侧主键三元组校验要求齐全)。
- **失败也记日志**:所有拒绝分支(鉴权、限流、上游失败等)都会写调用日志,`error_msg` 按 `api_call_log.error_msg` 长度(512)截断。
- **幂等回放响应头**:命中首次成功响应回放时返回 `X-Gateway-Replay: true`,并回写首次调用缓存的 `X-Gateway-Balance` 余额快照;此时不会再扣费、不会再调用上游。
- **计数时机**:QPS/日限额在转发上游**之前**占用,因此上游失败(502/504)也会计入调用量与 QPS。
- **计数时机**:QPS/日限额在转发上游**之前**占用,因此上游失败(502/504)与预检拒绝(40201)也会计入调用量与 QPS。
## 八、部署与本地启动
@@ -301,7 +308,9 @@ String signature = hmac.digestHex(signContent); // 小写十六进制
| `internal.api.ip-white-list` | `[]` | 允许来源 IP,空表示不限制 |
| `xss.excludeUrls` | 包含 `/internal/**` | 内网 JSON 调用不做 XSS 清洗 |
内网接口:`POST /internal/billing/deduct`、`POST /internal/calllog/record`,请求头 `X-Internal-Token`。
内网接口:`POST /internal/billing/precheck`、`POST /internal/billing/deduct`、`POST /internal/calllog/record`,请求头 `X-Internal-Token`。
> 升级顺序:先升级 admin(提供 `/internal/billing/precheck`),再升级网关。旧版 admin 上预检会 404,网关按降级策略继续转发并输出 WARN,不会阻断调用。
### 8.3 本地启动
@@ -317,7 +326,7 @@ INTERNAL_API_TOKEN=starapi-internal-token \
curl -s http://127.0.0.1:8082/actuator/health
```
本地没有 admin 时网关仍可转发上游,但扣费与调用日志会失败(不影响响应,仅 `X-Gateway-Warning: charge-failed` 与 error 日志)。
本地没有 admin 时网关仍可转发上游:转发前预检失败会降级放行,扣费与调用日志随后失败(不影响响应,仅 `X-Gateway-Warning: charge-failed` 与 error 日志)。
启动前可用 openapi 管理端触发一次“全量刷新网关缓存”,否则路由/凭证缓存为空,请求会直接返回 `40101` / `40401`。
### 8.4 Docker 与 nginx
@@ -1,5 +1,6 @@
package com.starsee.starapi.gateway.client;
import com.starsee.starapi.billing.api.domain.DeductPrecheckDTO;
import com.starsee.starapi.billing.api.domain.DeductRequestDTO;
import com.starsee.starapi.billing.api.domain.DeductResultDTO;
import com.starsee.starapi.common.core.domain.R;
@@ -11,8 +12,8 @@ import tools.jackson.core.type.TypeReference;
/**
* 计费扣费客户端
* <p>
* 网关转发上游成功后按次调用 admin 内网扣费接口,扣费成功后由网关异步写入调用日志。
* 扣费请求以 {@code requestId} 作为幂等键,同一链路重复调用不会重复扣费;
* 网关转发上游前调用 admin 内网预检接口判断本次调用是否可支付,转发成功后按次扣费,
* 再由网关异步写入调用日志。扣费请求以 {@code requestId} 作为幂等键,同一链路重复调用不会重复扣费;
* 扣费结果只用于日志与告警,不影响已经返回给调用方的上游响应。
*
* @author x6g2
@@ -31,6 +32,28 @@ public class BillingClient {
*/
private final GatewayProperties properties;
/**
* 扣费预检
* <p>
* 转发上游前判断本次调用是否还存在可用的付费来源(免费额度、次数包或钱包余额),
* 只读不扣费;预检接口本身不可用时由调用方决定是否降级继续转发。
*
* @param request 扣费请求
* @return 预检结果,接口未返回数据时返回 {@code null}
* @throws InternalApiException 内网预检接口调用失败时抛出
*/
public DeductPrecheckDTO precheck(DeductRequestDTO request) {
R<DeductPrecheckDTO> result = internalApiClient.post(
properties.getInternal().getPrecheckPath(),
request,
new TypeReference<R<DeductPrecheckDTO>>() {
});
if (result == null) {
return null;
}
return result.getData();
}
/**
* 按次扣费
* <p>
@@ -43,6 +43,11 @@ public class GatewayProperties {
*/
private Internal internal = new Internal();
/**
* 计费预检配置
*/
private Billing billing = new Billing();
/**
* 上游转发配置
*/
@@ -122,6 +127,19 @@ public class GatewayProperties {
}
/**
* 计费预检配置
*/
@Data
public static class Billing {
/**
* 是否在转发上游前做扣费预检,关闭后恢复「先转发、后扣费、扣费失败仅告警」的旧行为
*/
private boolean precheckEnabled = true;
}
/**
* 内网接口配置
*/
@@ -131,7 +149,7 @@ public class GatewayProperties {
/**
* admin 服务内网访问地址
*/
private String adminBaseUrl = "http://127.0.0.1:8080";
private String adminBaseUrl;
/**
* 内网调用令牌,需与 admin 的 internal.api.token 保持一致
@@ -153,6 +171,11 @@ public class GatewayProperties {
*/
private String deductPath = InternalApiConstants.BILLING_DEDUCT_PATH;
/**
* 计费预检内网接口路径
*/
private String precheckPath = InternalApiConstants.BILLING_PRECHECK_PATH;
/**
* 调用日志写入内网接口路径
*/
@@ -139,6 +139,21 @@ public interface GatewayConstants {
*/
String CHARGE_MODE_NONE = "0";
/**
* 计费模式-免费
*/
String CHARGE_TYPE_FREE = "0";
/**
* 计费模式-按次扣费
*/
String CHARGE_TYPE_PER_CALL = "1";
/**
* 计费模式-仅限套餐包
*/
String CHARGE_TYPE_PACKAGE = "2";
/**
* 应用默认QPS上限
*/
@@ -224,6 +239,11 @@ public interface GatewayConstants {
*/
int FILTER_ORDER_RATE_LIMIT = Ordered.HIGHEST_PRECEDENCE + 160;
/**
* 过滤器顺序-计费预检(余额/额度不足时在转发上游前拒绝)
*/
int FILTER_ORDER_BILLING = Ordered.HIGHEST_PRECEDENCE + 165;
/**
* 过滤器顺序-上游转发、扣费与响应回写
*/
@@ -40,6 +40,11 @@ public enum GatewayErrorCode {
*/
SIGNATURE_INVALID(40104, 401, "请求签名校验未通过"),
/**
* 账户余额或可用次数不足,无法支付本次调用
*/
BILLING_NOT_PAYABLE(40201, 402, "开发者账户余额或可用次数不足,请先充值或购买套餐"),
/**
* 应用已被禁用
*/
@@ -0,0 +1,135 @@
package com.starsee.starapi.gateway.filter;
import com.starsee.starapi.billing.api.domain.DeductPrecheckDTO;
import com.starsee.starapi.billing.api.domain.DeductRequestDTO;
import com.starsee.starapi.common.core.utils.StringUtils;
import com.starsee.starapi.gateway.client.BillingClient;
import com.starsee.starapi.gateway.config.GatewayProperties;
import com.starsee.starapi.gateway.constant.GatewayConstants;
import com.starsee.starapi.gateway.constant.GatewayErrorCode;
import com.starsee.starapi.gateway.model.GatewayRequestContext;
import com.starsee.starapi.gateway.support.GatewayResponseWriter;
import com.starsee.starapi.openapi.api.domain.ApiRouteDTO;
import com.starsee.starapi.openapi.api.domain.AppCredentialDTO;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.math.BigDecimal;
/**
* 网关计费预检过滤器
* <p>
* 位于限流之后、转发上游之前:按「免费试用额度 → 次数包 → 钱包余额」只读判断本次调用是否可支付,
* 无可用付费来源时直接返回 40201,避免先调用上游、扣费阶段才发现余额不足(即「没钱也能拿到结果」)。
* 免费接口与单价为 0 的按次接口不会扣钱包,跳过预检;预检接口本身不可用时降级继续转发,
* 由扣费阶段按 {@code X-Gateway-Warning: charge-failed} 兜底告警。
*
* @author x6g2
*/
@Slf4j
@Component
@Order(GatewayConstants.FILTER_ORDER_BILLING)
public class OpenApiBillingFilter extends AbstractOpenApiFilter {
/**
* 计费客户端
*/
private final BillingClient billingClient;
/**
* 构造计费预检过滤器
*
* @param properties 网关配置
* @param gatewayResponseWriter 网关响应写出器
* @param billingClient 计费客户端
*/
public OpenApiBillingFilter(GatewayProperties properties, GatewayResponseWriter gatewayResponseWriter,
BillingClient billingClient) {
super(properties, gatewayResponseWriter);
this.billingClient = billingClient;
}
/**
* 转发前做扣费预检,不可支付时直接拒绝本次调用
*
* @param request 当前请求
* @param response 当前响应
* @param filterChain 过滤器链
* @throws ServletException Servlet 异常
* @throws IOException IO 异常
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
GatewayRequestContext context = currentContext(request);
ApiRouteDTO route = context == null ? null : context.getRoute();
AppCredentialDTO credential = context == null ? null : context.getCredential();
if (context == null || route == null || credential == null || !needPrecheck(route)) {
filterChain.doFilter(request, response);
return;
}
DeductPrecheckDTO precheck = precheck(context, route, credential);
if (precheck != null && Boolean.FALSE.equals(precheck.getPayable())) {
reject(response, context, GatewayErrorCode.BILLING_NOT_PAYABLE, precheck.getReason());
return;
}
filterChain.doFilter(request, response);
}
/**
* 调用 admin 内网预检接口,接口不可用时返回 {@code null} 表示降级放行
*
* @param context 网关上下文
* @param route 命中的接口路由
* @param credential 调用方应用凭证
* @return 预检结果,降级时返回 {@code null}
*/
private DeductPrecheckDTO precheck(GatewayRequestContext context, ApiRouteDTO route,
AppCredentialDTO credential) {
DeductRequestDTO request = new DeductRequestDTO();
request.setRequestId(context.getRequestId());
request.setUserId(credential.getUserId());
request.setAppId(credential.getAppId());
request.setApiId(route.getApiId());
request.setChargeType(route.getChargeType());
request.setPricePerCall(route.getPricePerCall());
try {
return billingClient.precheck(request);
} catch (Exception e) {
// 预检只是费用前置校验,内网接口不可用时不能让网关整体不可用,继续转发并由扣费阶段兜底
log.warn("网关计费预检失败,降级继续转发 requestId={} appId={} apiId={}",
context.getRequestId(), credential.getAppId(), route.getApiId(), e);
return null;
}
}
/**
* 判断接口是否需要预检:免费接口与单价不大于 0 的按次接口不会扣钱包,直接放行;
* 仅限套餐包的接口必须校验次数包,按次接口需要校验免费额度、次数包与钱包余额
*
* @param route 命中的接口路由
* @return true=需要预检
*/
private boolean needPrecheck(ApiRouteDTO route) {
if (!properties.getBilling().isPrecheckEnabled()) {
return false;
}
String chargeType = StringUtils.blankToDefault(route.getChargeType(),
GatewayConstants.CHARGE_TYPE_PER_CALL);
if (StringUtils.equals(GatewayConstants.CHARGE_TYPE_FREE, chargeType)) {
return false;
}
if (StringUtils.equals(GatewayConstants.CHARGE_TYPE_PACKAGE, chargeType)) {
return true;
}
BigDecimal price = route.getPricePerCall();
return price != null && price.compareTo(BigDecimal.ZERO) > 0;
}
}
@@ -41,7 +41,9 @@ import java.util.Locale;
* <p>
* 按接口配置的超时时间把请求转发到上游,成功(2xx)后发起扣费并回写上游响应,
* 同时在响应头回写扣费后的账户余额,并缓存成功响应供相同请求ID回放;
* 扣费失败只追加告警头与错误日志,不改变调用方看到的结果。
* 余额或额度不足的调用通常已由 {@link OpenApiBillingFilter} 在转发前拒绝;
* 预检通过、预检关闭或降级时仍可能因并发消耗、内网接口异常导致扣费失败,
* 这里只追加告警头与错误日志,不改变调用方看到的结果。
*
* @author x6g2
*/
@@ -215,13 +217,14 @@ public class OpenApiForwardFilter extends AbstractOpenApiFilter {
}
/**
* 对成功调用发起扣费并回写账户余额响应头,扣费失败只记录告警,不影响已返回的上游结果
* 对成功调用发起扣费并回写账户余额响应头,
* 扣费失败(并发消耗、内网接口异常、预检关闭或降级)只记录告警,不影响已返回的上游结果
*
* @param context 网关上下文
* @param response 当前响应
*/
private void charge(GatewayRequestContext context, HttpServletResponse response) {
if (context.getHttpStatus() < 200 || context.getHttpStatus() >= 300) {
if (context.getHttpStatus() != 200 || context.getBizCode() != 200) {
return;
}
ApiRouteDTO route = context.getRoute();
@@ -2,7 +2,7 @@
gateway:
internal:
# admin 服务内网访问地址
admin-base-url: http://127.0.0.1:8080
admin-base-url: http://172.18.0.2:8080
--- # redis 单机配置(单机与集群只能开启一个另一个需要注释掉)
spring.data:
@@ -2,7 +2,7 @@
gateway:
internal:
# admin 服务内网访问地址(按实际内网部署地址调整)
admin-base-url: http://127.0.0.1:8080
admin-base-url: http://172.18.0.2:8080
--- # redis 单机配置(单机与集群只能开启一个另一个需要注释掉)
spring.data:
@@ -74,10 +74,14 @@ gateway:
response-ttl-seconds: 600
# 允许回放的响应体最大字节数
max-response-cache-bytes: 262144
# 内网接口(扣费 / 调用日志)
# 计费预检(转发上游前校验余额/次数包/免费额度)
billing:
# 关闭后恢复「先转发、后扣费、扣费失败仅告警」的旧行为
precheck-enabled: true
# 内网接口(预检 / 扣费 / 调用日志)
internal:
# admin 内网访问地址
admin-base-url: http://127.0.0.1:8080
admin-base-url: http://172.18.0.2:8080
# 内网调用令牌,需与 admin 的 internal.api.token 保持一致
token: ${INTERNAL_API_TOKEN:starapi-internal-token}
# 连接超时(毫秒)
@@ -2,6 +2,7 @@ package com.starsee.starapi.billing.controller.internal;
import lombok.RequiredArgsConstructor;
import com.starsee.starapi.billing.api.BillingService;
import com.starsee.starapi.billing.api.domain.DeductPrecheckDTO;
import com.starsee.starapi.billing.api.domain.DeductRequestDTO;
import com.starsee.starapi.billing.api.domain.DeductResultDTO;
import com.starsee.starapi.common.core.domain.R;
@@ -36,4 +37,16 @@ public class InternalBillingController {
return R.ok(billingService.deduct(request));
}
/**
* 扣费预检:按「免费试用额度 → 次数包 → 钱包余额」优先级只读判断本次调用是否可支付,
* 供网关在转发上游前拒绝余额或额度不足的调用;不扣减任何额度、不写资金流水
*
* @param request 扣费请求
* @return 预检结果,不可支付时含拒绝原因
*/
@PostMapping("/precheck")
public R<DeductPrecheckDTO> precheck(@RequestBody DeductRequestDTO request) {
return R.ok(billingService.precheck(request));
}
}
@@ -56,6 +56,15 @@ public interface IApiUserPackageService {
*/
Boolean grantByBo(ApiUserPackageBo bo);
/**
* 调用预检:判断用户是否存在可用于指定接口的有效次数包(接口包或通用包),只读不扣减
*
* @param userId 开发者用户ID
* @param apiId 调用的API ID(0为通用包)
* @return true=存在可用次数包,false=无可用次数包
*/
Boolean hasAvailableCalls(Long userId, Long apiId);
/**
* 调用扣减一次次数包用量:优先扣减最早过期的可用次数包,扣减后自动更新耗尽/过期状态
*
@@ -156,6 +156,21 @@ public class ApiUserPackageServiceImpl implements IApiUserPackageService {
return flag;
}
/**
* 调用预检:判断用户是否存在可用于指定接口的有效次数包(接口包或通用包),只读不扣减
*
* @param userId 开发者用户ID
* @param apiId 调用的API ID(0为通用包)
* @return true=存在可用次数包,false=无可用次数包
*/
@Override
public Boolean hasAvailableCalls(Long userId, Long apiId) {
if (ObjectUtil.isNull(userId)) {
return false;
}
return ObjectUtil.isNotNull(findAvailableCandidate(userId, apiId));
}
/**
* 调用扣减一次次数包用量:优先扣减最早过期的有效次数包,
* 通过条件更新保证并发场景下不会扣成负数,扣减至 0 时置为已耗尽
@@ -170,23 +185,10 @@ public class ApiUserPackageServiceImpl implements IApiUserPackageService {
if (ObjectUtil.isNull(userId)) {
return false;
}
Long matchedApiId = ObjectUtil.defaultIfNull(apiId, BillingConstants.PACKAGE_API_ID_ALL);
LocalDateTime now = LocalDateTime.now();
// 候选次数包:接口匹配或全平台通用包,按过期时间升序优先消耗最早到期的
List<ApiUserPackage> candidates = apiUserPackageMapper.lambda()
.eq(ApiUserPackage::getUserId, userId)
.eq(ApiUserPackage::getStatus, BillingConstants.USER_PACKAGE_STATUS_VALID)
.gt(ApiUserPackage::getRemainCalls, 0)
.gt(ApiUserPackage::getExpireTime, now)
.and(wrapper -> wrapper.eq(ApiUserPackage::getApiId, matchedApiId)
.or()
.eq(ApiUserPackage::getApiId, BillingConstants.PACKAGE_API_ID_ALL))
.orderByAsc(ApiUserPackage::getExpireTime, ApiUserPackage::getId)
.list(new Page<>(1, 1, false));
if (CollUtil.isEmpty(candidates)) {
ApiUserPackage candidate = findAvailableCandidate(userId, apiId);
if (ObjectUtil.isNull(candidate)) {
return false;
}
ApiUserPackage candidate = candidates.get(0);
boolean deducted = apiUserPackageMapper.lambda()
.setDecrBy(true, ApiUserPackage::getRemainCalls, 1)
.eq(ApiUserPackage::getId, candidate.getId())
@@ -205,6 +207,29 @@ public class ApiUserPackageServiceImpl implements IApiUserPackageService {
return true;
}
/**
* 查询优先消耗的可用次数包:接口匹配或全平台通用包,按过期时间升序取最早到期的
*
* @param userId 开发者用户ID
* @param apiId 调用的API ID(0为通用包)
* @return 可用次数包,无可用次数包时返回 {@code null}
*/
private ApiUserPackage findAvailableCandidate(Long userId, Long apiId) {
Long matchedApiId = ObjectUtil.defaultIfNull(apiId, BillingConstants.PACKAGE_API_ID_ALL);
LocalDateTime now = LocalDateTime.now();
List<ApiUserPackage> candidates = apiUserPackageMapper.lambda()
.eq(ApiUserPackage::getUserId, userId)
.eq(ApiUserPackage::getStatus, BillingConstants.USER_PACKAGE_STATUS_VALID)
.gt(ApiUserPackage::getRemainCalls, 0)
.gt(ApiUserPackage::getExpireTime, now)
.and(wrapper -> wrapper.eq(ApiUserPackage::getApiId, matchedApiId)
.or()
.eq(ApiUserPackage::getApiId, BillingConstants.PACKAGE_API_ID_ALL))
.orderByAsc(ApiUserPackage::getExpireTime, ApiUserPackage::getId)
.list(new Page<>(1, 1, false));
return CollUtil.isEmpty(candidates) ? null : candidates.get(0);
}
/**
* 校验购买/发放入参中的用户与套餐规格标识
*
@@ -2,6 +2,7 @@ package com.starsee.starapi.billing.service.impl;
import cn.hutool.core.util.ObjectUtil;
import com.starsee.starapi.billing.api.BillingService;
import com.starsee.starapi.billing.api.domain.DeductPrecheckDTO;
import com.starsee.starapi.billing.api.domain.DeductRequestDTO;
import com.starsee.starapi.billing.api.domain.DeductResultDTO;
import com.starsee.starapi.billing.api.domain.WalletDTO;
@@ -25,7 +26,8 @@ import java.time.Duration;
/**
* 按次计费引擎:按「免费试用额度 → 次数包 → 钱包余额」优先级完成一次调用计费。
* <p>
* 同一 {@code requestId} 的重复扣费请求通过 Redis 幂等锁与结果缓存保证只扣一次。
* 同一 {@code requestId} 的重复扣费请求通过 Redis 幂等锁与结果缓存保证只扣一次;
* 转发前的扣费预检复用同一优先级判断,只读不落库。
*
* @author x6g2
* @date 2026-11-01
@@ -76,6 +78,58 @@ public class BillingChargeServiceImpl implements BillingService {
}
}
/**
* 扣费预检:按「免费试用额度 → 次数包 → 钱包余额」优先级只读判断本次调用是否存在可用的付费来源,
* 供网关在转发上游前拒绝余额或额度不足的调用;不扣减任何额度、不写资金流水。
* <p>
* 判定顺序需与 {@link #doDeduct(DeductRequestDTO)} 保持一致:免费接口 → 免费额度 → 次数包 →
* 仅套餐包限制 → 单价 → 钱包(未开户/冻结/余额不足),否则会出现「预检放行但扣费失败」或误拒。
*
* @param request 扣费请求
* @return 预检结果,不可支付时 {@code reason} 为拒绝原因
*/
@Override
public DeductPrecheckDTO precheck(DeductRequestDTO request) {
validateRequest(request);
String chargeType = StringUtils.isBlank(request.getChargeType())
? BillingConstants.CHARGE_TYPE_PER_CALL : request.getChargeType();
if (StringUtils.equals(BillingConstants.CHARGE_TYPE_FREE, chargeType)) {
return buildPrecheck(true, BillingConstants.CHARGE_MODE_NONE, BigDecimal.ZERO, null, "免费接口,不计费");
}
// 1. 免费试用额度
if (appAuthService.hasFreeCall(request.getAppId(), request.getApiId())) {
return buildPrecheck(true, BillingConstants.CHARGE_MODE_FREE_CALL, BigDecimal.ZERO, null,
"可使用免费试用额度");
}
// 2. 可用次数包
if (Boolean.TRUE.equals(apiUserPackageService.hasAvailableCalls(request.getUserId(), request.getApiId()))) {
return buildPrecheck(true, BillingConstants.CHARGE_MODE_PACKAGE, BigDecimal.ZERO, null, "可使用次数包");
}
if (StringUtils.equals(BillingConstants.CHARGE_TYPE_PACKAGE, chargeType)) {
// 仅限套餐包计费的接口不允许回落到钱包余额
return buildPrecheck(false, BillingConstants.CHARGE_MODE_NONE, BigDecimal.ZERO, null,
"该接口仅支持套餐包调用,当前无可用套餐包次数");
}
// 3. 钱包余额:接口未配置单价时不扣费,无需校验余额
BigDecimal amount = normalizeAmount(request.getPricePerCall());
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
return buildPrecheck(true, BillingConstants.CHARGE_MODE_NONE, BigDecimal.ZERO, null,
"接口未配置调用单价,不计费");
}
ApiUserWalletVo wallet = apiUserWalletService.queryByUserId(request.getUserId());
if (ObjectUtil.isNull(wallet)) {
return buildPrecheck(false, BillingConstants.CHARGE_MODE_NONE, amount, null, "钱包不存在,请先开户");
}
BigDecimal balance = ObjectUtil.defaultIfNull(wallet.getBalance(), BigDecimal.ZERO);
if (BillingConstants.WALLET_STATUS_FROZEN.equals(wallet.getStatus())) {
return buildPrecheck(false, BillingConstants.CHARGE_MODE_NONE, amount, balance, "钱包已冻结,无法扣费");
}
if (balance.compareTo(amount) < 0) {
return buildPrecheck(false, BillingConstants.CHARGE_MODE_NONE, amount, balance, "钱包余额不足,请先充值");
}
return buildPrecheck(true, BillingConstants.CHARGE_MODE_WALLET, amount, balance, "可使用钱包余额");
}
/**
* 查询开发者钱包资产
*
@@ -102,7 +156,7 @@ public class BillingChargeServiceImpl implements BillingService {
}
/**
* 执行实际扣费流程
* 执行实际扣费流程,判定顺序需与 {@link #precheck(DeductRequestDTO)} 保持一致
*
* @param request 扣费请求
* @return 扣费结果
@@ -217,4 +271,25 @@ public class BillingChargeServiceImpl implements BillingService {
return result;
}
/**
* 构建扣费预检结果
*
* @param payable 本次调用是否可继续
* @param chargeMode 预计扣费模式
* @param amount 预计扣减金额
* @param balance 钱包当前可用余额
* @param reason 判定说明,不可支付时为拒绝原因
* @return 扣费预检结果
*/
private DeductPrecheckDTO buildPrecheck(boolean payable, String chargeMode, BigDecimal amount,
BigDecimal balance, String reason) {
DeductPrecheckDTO result = new DeductPrecheckDTO();
result.setPayable(payable);
result.setChargeMode(chargeMode);
result.setEstimatedAmount(amount);
result.setBalance(balance);
result.setReason(reason);
return result;
}
}
@@ -326,6 +326,26 @@ public class ApiAppServiceImpl implements IApiAppService, AppAuthService {
.exists();
}
/**
* 判断应用在指定接口上是否还有剩余免费试用次数,供计费预检只读判断
*
* @param appId 应用ID
* @param apiId 接口ID
* @return true=授权已开通且剩余免费次数大于 0,false=无可用免费次数
*/
@Override
public boolean hasFreeCall(Long appId, Long apiId) {
if (appId == null || apiId == null) {
return false;
}
return apiAppAuthMapper.lambda()
.eq(ApiAppAuth::getAppId, appId)
.eq(ApiAppAuth::getApiId, apiId)
.eq(ApiAppAuth::getAuthStatus, OpenApiConstants.AUTH_STATUS_OPEN)
.gt(ApiAppAuth::getFreeCallsLeft, 0)
.exists();
}
/**
* 原子扣减一次应用在指定接口上的免费试用次数,供计费引擎按「免费额度」优先扣费
* <p>