feat: 新增扣费预检请求
This commit is contained in:
@@ -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);
|
||||
|
||||
/**
|
||||
* 查询开发者钱包资产
|
||||
*
|
||||
|
||||
+48
@@ -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;
|
||||
|
||||
}
|
||||
+5
@@ -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
@@ -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
|
||||
|
||||
+25
-2
@@ -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>
|
||||
|
||||
+24
-1
@@ -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;
|
||||
|
||||
/**
|
||||
* 调用日志写入内网接口路径
|
||||
*/
|
||||
|
||||
+20
@@ -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;
|
||||
|
||||
/**
|
||||
* 过滤器顺序-上游转发、扣费与响应回写
|
||||
*/
|
||||
|
||||
+5
@@ -40,6 +40,11 @@ public enum GatewayErrorCode {
|
||||
*/
|
||||
SIGNATURE_INVALID(40104, 401, "请求签名校验未通过"),
|
||||
|
||||
/**
|
||||
* 账户余额或可用次数不足,无法支付本次调用
|
||||
*/
|
||||
BILLING_NOT_PAYABLE(40201, 402, "开发者账户余额或可用次数不足,请先充值或购买套餐"),
|
||||
|
||||
/**
|
||||
* 应用已被禁用
|
||||
*/
|
||||
|
||||
+135
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+6
-3
@@ -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}
|
||||
# 连接超时(毫秒)
|
||||
|
||||
+13
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+9
@@ -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);
|
||||
|
||||
/**
|
||||
* 调用扣减一次次数包用量:优先扣减最早过期的可用次数包,扣减后自动更新耗尽/过期状态
|
||||
*
|
||||
|
||||
+40
-15
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验购买/发放入参中的用户与套餐规格标识
|
||||
*
|
||||
|
||||
+77
-2
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+20
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user