223 lines
8.1 KiB
Markdown
223 lines
8.1 KiB
Markdown
# Distribution API
|
||
|
||
## 1. 行为契约
|
||
|
||
Distribution API 只提供一次性独占提取:
|
||
|
||
```text
|
||
读取 Redis TTL 活动池中的 AVAILABLE 候选
|
||
-> 校验 Gateway 预留、TTL、健康、所有权与过滤条件
|
||
-> Redis 原子移出可分配池并写入短期幂等结果
|
||
-> 返回真实代理地址
|
||
```
|
||
|
||
Redis 原子操作成功前不得把地址写给 Client。返回成功后,该 Proxy 在当前 TTL
|
||
生命周期中不再参与 Gateway、再次提取或可用库存统计。系统不跟踪 Client 是否
|
||
使用、使用并发或何时停止,也不提供 release、renew、status 或租约端点。短效
|
||
Proxy 明细只存在于 Redis TTL 活动池和节点内存;PostgreSQL 不保存 Proxy 明细
|
||
或逐次提取记录。
|
||
|
||
HTTP 契约源文件:`api/openapi/proxy-pool.yaml`。
|
||
|
||
## 2. 提取请求
|
||
|
||
```http
|
||
POST /api/v1/proxies/extract HTTP/1.1
|
||
Host: 127.0.0.1:8081
|
||
Content-Type: application/json
|
||
X-API-Key: TOKEN
|
||
X-Request-ID: req_01J4EXAMPLE
|
||
Idempotency-Key: extract-01J4EXAMPLE
|
||
|
||
```
|
||
|
||
PowerShell 调用示例:
|
||
|
||
```powershell
|
||
$body = @{
|
||
count = 5
|
||
fulfillment = "partial"
|
||
filters = @{ protocols = @("http"); regions = @("shanghai") }
|
||
} | ConvertTo-Json -Depth 4
|
||
|
||
Invoke-RestMethod `
|
||
-Method Post `
|
||
-Uri http://127.0.0.1:8081/api/v1/proxies/extract `
|
||
-Headers @{ "X-API-Key" = "TOKEN"; "Idempotency-Key" = "extract-SERIAL" } `
|
||
-ContentType application/json `
|
||
-Body $body
|
||
```
|
||
|
||
请求约束:
|
||
|
||
- `count` 至少为 1,且不超过服务端 `maxCountPerRequest`。
|
||
- `fulfillment` 省略时使用服务端配置,默认 `partial`。
|
||
- 所有过滤数组执行“数组内 OR、不同维度 AND”。空数组等同不限制。
|
||
- 每个过滤维度最多包含 64 个值,且数组内不得重复。
|
||
- `allowedUpstreams` 只能缩小 Client 可访问的 Upstream 集,不能扩大权限。
|
||
|
||
## 3. 成功响应
|
||
|
||
```json
|
||
{
|
||
"requestId": "req_01J4EXAMPLE",
|
||
"requested": 5,
|
||
"returned": 2,
|
||
"proxies": [
|
||
{
|
||
"id": "px_01J4A",
|
||
"protocol": "http",
|
||
"host": "192.0.2.10",
|
||
"port": 8080,
|
||
"username": "USER",
|
||
"password": "PASSWORD",
|
||
"url": "http://USER:PASSWORD@192.0.2.10:8080",
|
||
"region": "shanghai",
|
||
"carrier": "telecom",
|
||
"upstream": "provider-a",
|
||
"expiresAt": "2026-07-28T10:30:00Z",
|
||
"remainingTtlSeconds": 83,
|
||
"extractedAt": "2026-07-28T10:28:37Z"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
`returned` 必须等于 `proxies` 数组长度。`partial` 模式中 `returned` 可以为
|
||
零;这仍表示请求语法有效,只是当前没有可提取库存。
|
||
|
||
返回的 `password` 和 `url` 含真实凭据。Client 必须限制日志、追踪和错误上报
|
||
对响应体的采集。服务端访问日志只记录数量、过滤摘要、Client、Upstream 和
|
||
Request ID,不记录地址或凭据。
|
||
|
||
## 4. 数量语义
|
||
|
||
### 4.1 partial
|
||
|
||
符合条件数量少于 `count` 时,原子提交实际数量:
|
||
|
||
```text
|
||
requested=10, eligible=6, reserve=0 -> returned=6
|
||
```
|
||
|
||
### 4.2 allOrNothing
|
||
|
||
锁定数量不足时整个事务回滚:
|
||
|
||
```json
|
||
{
|
||
"type": "https://proxy-pool.local/problems/insufficient-proxies",
|
||
"title": "Insufficient proxies",
|
||
"status": 409,
|
||
"code": "INSUFFICIENT_PROXIES",
|
||
"detail": "requested 10 proxies but only 6 are currently eligible",
|
||
"requestId": "req_01J4EXAMPLE"
|
||
}
|
||
```
|
||
|
||
冲突响应后,候选 Proxy 仍在 Redis TTL 活动池中保持 AVAILABLE。
|
||
|
||
## 5. 资格过滤与 Gateway 预留
|
||
|
||
候选必须同时满足:
|
||
|
||
1. Redis TTL 活动池条目仍为 AVAILABLE,且当前时间早于按供应商安全余量计算的
|
||
`usableUntil`。
|
||
2. 没有 Worker 所有权,或已完成 Drain 且 Active/Reserved 均为零。
|
||
3. 剩余 TTL 不低于 `minRemainingTTL`。
|
||
4. 最近健康检查不早于 `maxHealthCheckAge`。
|
||
5. protocol、region、carrier、Upstream 满足过滤与 Client 权限。
|
||
6. 提取后符合条件的共享库存不低于 `reserveForGateway`。
|
||
|
||
Worker-owned Proxy 不得直接提取。Controller 需要先发布 DRAINING,等待 Worker
|
||
确认没有 Active/Reserved,再清除 Redis 中的所有权并进入原子提取。快照延迟时
|
||
仍禁止 Gateway 与 Client 同时获得同一 Proxy。
|
||
|
||
## 6. 并发与原子性
|
||
|
||
Redis Adapter 应通过单个 Lua 脚本、Redis Function 或等价的原子原语完成候选
|
||
筛选、TTL/健康/所有权复核、Gateway 预留计算、满足模式判断、从可分配池移除
|
||
所选条目,并写入短期幂等结果。提取过程中不访问 PostgreSQL。
|
||
|
||
关键不变量:
|
||
|
||
- 两个并发成功响应的 Proxy ID 集合交集为空。
|
||
- 原子操作失败时整个批次不返回,也不得留下部分移除结果。
|
||
- `allOrNothing` 不足时零个条目退出活动池。
|
||
- Redis 活动池不可用时返回 503,不以内存副本冒充成功。
|
||
- PostgreSQL 不可用不阻断提取;需要 PostgreSQL 的 Admin 管理写入单独降级。
|
||
|
||
## 7. 幂等
|
||
|
||
提取是消耗库存的写操作。客户端收到超时后盲目重试可能再次提取一批不同
|
||
Proxy,因此自动重试应提供稳定的 `Idempotency-Key`。
|
||
|
||
服务端在 Redis 中保存有界 TTL 的幂等记录,至少包含 Client ID、Key、请求体
|
||
摘要、提交结果和过期时间。同一 Client、同一 Key、相同摘要返回首次结果;
|
||
摘要不同返回 409。幂等结果必须和活动池状态变更处于同一个 Redis 原子操作。
|
||
TTL 到期或 Redis 数据丢失后不再保证旧 Key 去重,系统不回退到 PostgreSQL
|
||
保存逐次提取事实。
|
||
|
||
## 8. 认证、识别与限制
|
||
|
||
认证由部署配置决定,OpenAPI 同时声明 API Key、Basic、Bearer 和无认证场景。
|
||
无认证并不关闭来源 CIDR、Client 识别和限流:
|
||
|
||
- 直连请求使用来源 IP 形成匿名 Client。
|
||
- 只有来源属于 `trustedProxies` 时才接受转发头。
|
||
- 全局和每 Client 限流在查询库存前执行。
|
||
- 过滤条件、数量、请求体和 Header 都有长度/数量上限。
|
||
|
||
## 9. 错误模型
|
||
|
||
所有非 2xx 响应使用 `application/problem+json`:
|
||
|
||
- `400`:JSON、Header 或基本格式无效。
|
||
- `401`:认证失败。
|
||
- `403`:来源控制、权限或 Upstream 访问被拒绝。
|
||
- `409`:allOrNothing 库存不足,或幂等 Key 冲突。
|
||
- `413`:请求体超过 Distribution 配置上限。
|
||
- `415`:请求体不是 `application/json`。
|
||
- `422`:数量、枚举或过滤组合违反业务约束。
|
||
- `429`:全局或 Client 速率限制,响应 `Retry-After`。
|
||
- `503`:Redis 活动池不可用、原子提取不可执行或服务正在排空。
|
||
- `500`:未分类的内部错误;响应不包含底层错误文本。
|
||
|
||
错误响应不得包含 Provider Secret、Proxy 凭据、SQL 或内部拓扑。
|
||
|
||
## 10. 短期运行记录与数据最小化
|
||
|
||
Redis 幂等结果只在配置的 TTL 窗口内保留重放响应所需的数据:
|
||
|
||
```text
|
||
clientId, idempotencyKey, requestDigest, response, expiresAt
|
||
```
|
||
|
||
无认证时 `clientId` 使用可信代理链解析后的规范化来源 IP 稳定标识。应用日志
|
||
仅记录 Request ID、数量、结果码和过滤摘要,不记录 Proxy 地址或凭据。需要长期
|
||
分析时只向 PostgreSQL 写入不含 Proxy 明细的可选聚合指标;系统不创建或归档
|
||
逐个 Proxy 的 Extraction Record。
|
||
|
||
## 11. 运行时实现边界
|
||
|
||
`distribution.Handler` 是薄 HTTP Adapter,只负责严格解码、Header/DTO 校验、
|
||
身份结果注入、错误映射和健康探针。独占提取、TTL、Gateway 预留及幂等事务
|
||
继续由 `extraction.Service` 和 Redis 活动池 Adapter 承担。当前内存 Store 仅作为
|
||
原子行为的参考实现与测试替身,不是生产持久化层。
|
||
|
||
请求体解码、Request ID 与 Problem JSON 统一复用 `platform/httpapi`。认证、
|
||
可信代理、来源控制、Client ID 和准入限流由必需的 `IdentityResolver` 注入,
|
||
标准装配使用 `httpsecurity.Protection`;解析结果至少包含稳定 Client ID 或
|
||
Source IP,且安全检查先于请求体解析。`controller/runtime` 将 Distribution
|
||
与 Admin 装配到不同 `net.Listener`,健康端点保持公开,提取端点执行独立认证。
|