1291 lines
42 KiB
Markdown
1291 lines
42 KiB
Markdown
# GoEdge HTTPDNS 技术实施方案
|
||
|
||
> 版本: 1.0 | 作者: AI Assistant | 日期: 2026-02-09
|
||
|
||
---
|
||
|
||
## 一、项目概述
|
||
|
||
### 1.1 目标
|
||
在 GoEdge 平台实现完整的 HTTPDNS 服务,包括:
|
||
- 基于 HTTPS 的 DNS 解析接口
|
||
- 动态指纹校验(WAF)
|
||
- App 管理后台
|
||
- 移动端 SDK 示例
|
||
|
||
### 1.2 设计决策
|
||
| 决策项 | 选择 | 理由 |
|
||
|--------|------|------|
|
||
| WAF 指纹校验 | 必须 | 防止非法请求绕过解析直接攻击源站 |
|
||
| App 管理界面 | 必须 | 标准产品功能,支持多租户 |
|
||
| SDK 示例 | 提供 | 降低客户接入成本 |
|
||
| 部署位置 | 复用 Edge-DNS | 减少运维复杂度 |
|
||
| 接口路径 | 新增 `/httpdns/resolve` | 保持向后兼容 |
|
||
|
||
---
|
||
|
||
## 二、系统架构
|
||
|
||
### 2.1 整体架构图
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────┐
|
||
│ 移动端 App │
|
||
│ ┌───────────────────────────────────────────────────────────────────┐ │
|
||
│ │ HTTPDNS SDK │ │
|
||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
|
||
│ │ │ Resolver │ │CacheManager │ │NetworkMonitor│ │ │
|
||
│ │ │ 解析引擎 │ │ 缓存管理 │ │ 网络感知 │ │ │
|
||
│ │ │ -多节点容错 │ │ -内存LRU │ │ -切换监听 │ │ │
|
||
│ │ │ -超时降级 │ │ -持久化 │ │ -自动清缓存 │ │ │
|
||
│ │ └─────────────┘ │ -软过期 │ │ -IPv6检测 │ │ │
|
||
│ │ └─────────────┘ └─────────────┘ │ │
|
||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
|
||
│ │ │ Signer │ │ Reporter │ │ Prefetch │ │ │
|
||
│ │ │ 签名模块 │ │ 监控上报 │ │ 预解析 │ │ │
|
||
│ │ │ -HMAC-SHA256│ │ -成功率 │ │ -冷启动优化 │ │ │
|
||
│ │ │ -防重放 │ │ -耗时统计 │ │ -批量解析 │ │ │
|
||
│ │ └─────────────┘ │ -缓存命中率│ └─────────────┘ │ │
|
||
│ │ └─────────────┘ │ │
|
||
│ └───────────────────────────────────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────────────────────────────────┘
|
||
│
|
||
│ HTTPS
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────┐
|
||
│ GoEdge 平台 │
|
||
│ │
|
||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||
│ │ Edge-DNS │ │ Edge-Node │ │ Edge-Admin │ │
|
||
│ │ │ │ │ │ │ │
|
||
│ │ /httpdns/ │ │ WAF 校验 │ │ App 管理 │ │
|
||
│ │ resolve │ │ 指纹验证 │ │ AppID/Secret│ │
|
||
│ │ │ │ │ │ │ │
|
||
│ │ 智能调度 │ │ 流量转发 │ │ SDK统计接收 │ │
|
||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||
│ │ │ │ │
|
||
│ └───────────────────┴───────────────────┘ │
|
||
│ │ │
|
||
│ ┌──────────────┐ │
|
||
│ │ Edge-API │ │
|
||
│ │ 数据服务 │ │
|
||
│ └──────────────┘ │
|
||
│ │ │
|
||
│ ┌──────────────┐ │
|
||
│ │ MySQL │ │
|
||
│ └──────────────┘ │
|
||
└─────────────────────────────────────────────────────────────────────────┘
|
||
│
|
||
│ HTTPS (IP 直连)
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────┐
|
||
│ 源站服务器 │
|
||
└─────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 2.2 请求流程(带缓存与降级)
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant App as 移动 App
|
||
participant Cache as SDK缓存
|
||
participant Resolver as SDK解析器
|
||
participant DNS as Edge-DNS
|
||
participant SysDNS as 系统DNS
|
||
participant Node as Edge-Node
|
||
participant API as Edge-API
|
||
|
||
Note over App,API: 阶段一:DNS 解析(含缓存与降级)
|
||
|
||
App->>Resolver: resolve("api.example.com")
|
||
|
||
Resolver->>Cache: 查询缓存
|
||
alt 缓存有效
|
||
Cache-->>Resolver: 返回 IP (命中)
|
||
Resolver-->>App: ["1.2.3.4"]
|
||
else 缓存软过期
|
||
Cache-->>Resolver: 返回 IP + 标记需刷新
|
||
Resolver-->>App: ["1.2.3.4"] (先返回)
|
||
Resolver->>DNS: 后台异步刷新
|
||
DNS-->>Resolver: 新 IP
|
||
Resolver->>Cache: 更新缓存
|
||
else 缓存完全过期或不存在
|
||
Resolver->>DNS: GET /httpdns/resolve
|
||
alt HTTPDNS 正常
|
||
DNS-->>Resolver: {"ips": ["1.2.3.4"], "ttl": 600}
|
||
Resolver->>Cache: 写入缓存
|
||
Resolver-->>App: ["1.2.3.4"]
|
||
else HTTPDNS 超时/失败
|
||
Resolver->>SysDNS: 降级到系统 DNS
|
||
SysDNS-->>Resolver: 1.2.3.4
|
||
Resolver-->>App: ["1.2.3.4"] (降级)
|
||
end
|
||
end
|
||
|
||
Note over App,API: 阶段二:业务请求(带签名)
|
||
App->>Node: HTTPS://1.2.3.4/v1/user + 签名Header
|
||
Node->>API: 查询 AppSecret
|
||
API-->>Node: AppSecret
|
||
Node->>Node: HMAC-SHA256 验证
|
||
alt 验证失败
|
||
Node-->>App: 403 Forbidden
|
||
else 验证成功
|
||
Node-->>App: 200 OK + 响应数据
|
||
end
|
||
|
||
Note over App,API: 阶段三:监控上报(异步)
|
||
Resolver-->>API: 定时上报统计数据
|
||
```
|
||
|
||
### 2.3 网络切换处理流程
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
A[WiFi] -->|切换| B[4G/5G]
|
||
B --> C{NetworkMonitor检测}
|
||
C --> D[清空所有缓存]
|
||
D --> E[下次请求重新解析]
|
||
E --> F[获取新网络最优IP]
|
||
```
|
||
|
||
---
|
||
|
||
## 三、模块详细设计
|
||
|
||
### 3.1 Edge-DNS: HTTPDNS 解析接口
|
||
|
||
#### 3.1.1 接口定义
|
||
|
||
| 项目 | 说明 |
|
||
|------|------|
|
||
| **Endpoint** | `GET /httpdns/resolve` |
|
||
| **协议** | HTTPS (443) |
|
||
| **认证** | 无(解析接口公开,业务请求才需要签名) |
|
||
|
||
#### 3.1.2 请求参数
|
||
|
||
| 参数 | 类型 | 必填 | 说明 |
|
||
|------|------|------|------|
|
||
| `host` | string | 是 | 待解析的域名 |
|
||
| `type` | string | 否 | 记录类型,默认 `A,AAAA`(同时返回) |
|
||
| `ip` | string | 否 | 客户端 IP(用于调试/代理场景) |
|
||
|
||
#### 3.1.3 响应格式
|
||
|
||
```json
|
||
{
|
||
"status": "ok",
|
||
"dns_server_time": 1700000000,
|
||
"client_ip": "114.114.114.114",
|
||
"data": [
|
||
{
|
||
"host": "api.example.com",
|
||
"type": "A",
|
||
"ips": ["1.1.1.1", "1.1.1.2"],
|
||
"ips_v6": ["240e:xxx::1"],
|
||
"ttl": 600
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
#### 3.1.4 代码实现
|
||
|
||
**文件**: `EdgeDNS/internal/nodes/httpdns.go` (新建)
|
||
|
||
```go
|
||
package nodes
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// HTTPDNSResponse HTTPDNS 响应结构
|
||
type HTTPDNSResponse struct {
|
||
Status string `json:"status"`
|
||
DNSServerTime int64 `json:"dns_server_time"`
|
||
ClientIP string `json:"client_ip"`
|
||
Data []HTTPDNSRecord `json:"data"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// HTTPDNSRecord 单条解析记录
|
||
type HTTPDNSRecord struct {
|
||
Host string `json:"host"`
|
||
Type string `json:"type"`
|
||
IPs []string `json:"ips"`
|
||
IPsV6 []string `json:"ips_v6"`
|
||
TTL int `json:"ttl"`
|
||
}
|
||
|
||
// handleHTTPDNSResolve 处理 HTTPDNS 解析请求
|
||
func (this *Server) handleHTTPDNSResolve(writer http.ResponseWriter, req *http.Request) {
|
||
writer.Header().Set("Content-Type", "application/json")
|
||
writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||
|
||
// 1. 解析参数
|
||
query := req.URL.Query()
|
||
host := strings.TrimSpace(query.Get("host"))
|
||
if host == "" {
|
||
this.writeHTTPDNSError(writer, "missing 'host' parameter")
|
||
return
|
||
}
|
||
|
||
// 2. 获取客户端 IP
|
||
clientIP := query.Get("ip")
|
||
if clientIP == "" {
|
||
clientIP = this.extractClientIP(req)
|
||
}
|
||
|
||
// 3. 查询 A 记录
|
||
ipsV4 := this.resolveRecords(host, "A", clientIP)
|
||
|
||
// 4. 查询 AAAA 记录
|
||
ipsV6 := this.resolveRecords(host, "AAAA", clientIP)
|
||
|
||
// 5. 获取 TTL
|
||
ttl := this.getRecordTTL(host, clientIP)
|
||
if ttl == 0 {
|
||
ttl = 600
|
||
}
|
||
|
||
// 6. 构造响应
|
||
resp := HTTPDNSResponse{
|
||
Status: "ok",
|
||
DNSServerTime: time.Now().Unix(),
|
||
ClientIP: clientIP,
|
||
Data: []HTTPDNSRecord{{
|
||
Host: host,
|
||
Type: "A",
|
||
IPs: ipsV4,
|
||
IPsV6: ipsV6,
|
||
TTL: ttl,
|
||
}},
|
||
}
|
||
|
||
json.NewEncoder(writer).Encode(resp)
|
||
}
|
||
|
||
// extractClientIP 从请求中提取客户端真实 IP
|
||
func (this *Server) extractClientIP(req *http.Request) string {
|
||
xff := req.Header.Get("X-Forwarded-For")
|
||
if xff != "" {
|
||
parts := strings.Split(xff, ",")
|
||
return strings.TrimSpace(parts[0])
|
||
}
|
||
xri := req.Header.Get("X-Real-IP")
|
||
if xri != "" {
|
||
return xri
|
||
}
|
||
host, _, _ := net.SplitHostPort(req.RemoteAddr)
|
||
return host
|
||
}
|
||
|
||
// resolveRecords 解析指定类型的记录
|
||
func (this *Server) resolveRecords(host, recordType, clientIP string) []string {
|
||
var result []string
|
||
if !strings.HasSuffix(host, ".") {
|
||
host += "."
|
||
}
|
||
domain, recordName := sharedDomainManager.SplitDomain(host)
|
||
if domain == nil {
|
||
return result
|
||
}
|
||
routeCodes := sharedRouteManager.FindRouteCodes(clientIP, domain.UserId)
|
||
records, _ := sharedRecordManager.FindRecords(domain.Id, routeCodes, recordName, recordType, false)
|
||
for _, record := range records {
|
||
if record.Value != "" {
|
||
result = append(result, record.Value)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// getRecordTTL 获取记录 TTL
|
||
func (this *Server) getRecordTTL(host, clientIP string) int {
|
||
if !strings.HasSuffix(host, ".") {
|
||
host += "."
|
||
}
|
||
domain, recordName := sharedDomainManager.SplitDomain(host)
|
||
if domain == nil {
|
||
return 0
|
||
}
|
||
routeCodes := sharedRouteManager.FindRouteCodes(clientIP, domain.UserId)
|
||
records, _ := sharedRecordManager.FindRecords(domain.Id, routeCodes, recordName, "A", false)
|
||
if len(records) > 0 {
|
||
return int(records[0].Ttl)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// writeHTTPDNSError 写入错误响应
|
||
func (this *Server) writeHTTPDNSError(writer http.ResponseWriter, errMsg string) {
|
||
writer.WriteHeader(http.StatusBadRequest)
|
||
resp := HTTPDNSResponse{
|
||
Status: "error",
|
||
DNSServerTime: time.Now().Unix(),
|
||
Error: errMsg,
|
||
}
|
||
json.NewEncoder(writer).Encode(resp)
|
||
}
|
||
```
|
||
|
||
**修改**: `EdgeDNS/internal/nodes/server.go` 第735行附近
|
||
|
||
```go
|
||
func (this *Server) handleHTTP(writer http.ResponseWriter, req *http.Request) {
|
||
if req.URL.Path == "/dns-query" {
|
||
this.handleHTTPDNSMessage(writer, req)
|
||
return
|
||
}
|
||
// 新增 HTTPDNS JSON API
|
||
if req.URL.Path == "/httpdns/resolve" {
|
||
this.handleHTTPDNSResolve(writer, req)
|
||
return
|
||
}
|
||
if req.URL.Path == "/resolve" {
|
||
this.handleHTTPJSONAPI(writer, req)
|
||
return
|
||
}
|
||
writer.WriteHeader(http.StatusNotFound)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 3.2 Edge-Node: WAF 指纹校验
|
||
|
||
#### 3.2.1 校验逻辑
|
||
|
||
| 步骤 | 说明 |
|
||
|------|------|
|
||
| 1 | 提取 `X-GE-AppID`, `X-GE-Timestamp`, `X-GE-Token` |
|
||
| 2 | 根据 AppID 查询 AppSecret |
|
||
| 3 | 校验时间戳(±300 秒内有效) |
|
||
| 4 | 计算 HMAC-SHA256 并比对 |
|
||
|
||
#### 3.2.2 代码实现
|
||
|
||
**文件**: `EdgeNode/internal/waf/checkpoints/checkpoint_httpdns_fingerprint.go` (新建)
|
||
|
||
```go
|
||
package checkpoints
|
||
|
||
import (
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"math"
|
||
"net/http"
|
||
"strconv"
|
||
"time"
|
||
|
||
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
|
||
"github.com/TeaOSLab/EdgeNode/internal/rpc"
|
||
)
|
||
|
||
type CheckpointHTTPDNSFingerprint struct {
|
||
Checkpoint
|
||
}
|
||
|
||
func (this *CheckpointHTTPDNSFingerprint) RequestValue(
|
||
req CheckpointRequest, param string, options map[string]string, ruleId int64,
|
||
) (value any, hasRequestBody bool, sysErr error, userErr error) {
|
||
httpReq, ok := req.WAFRaw().(*http.Request)
|
||
if !ok {
|
||
return "INVALID_REQUEST", false, nil, nil
|
||
}
|
||
|
||
appID := httpReq.Header.Get("X-GE-AppID")
|
||
token := httpReq.Header.Get("X-GE-Token")
|
||
tsStr := httpReq.Header.Get("X-GE-Timestamp")
|
||
|
||
if appID == "" && token == "" && tsStr == "" {
|
||
return "", false, nil, nil
|
||
}
|
||
if appID == "" {
|
||
return "MISSING_APPID", false, nil, nil
|
||
}
|
||
if token == "" {
|
||
return "MISSING_TOKEN", false, nil, nil
|
||
}
|
||
if tsStr == "" {
|
||
return "MISSING_TIMESTAMP", false, nil, nil
|
||
}
|
||
|
||
appSecret, err := this.getAppSecret(appID)
|
||
if err != nil || appSecret == "" {
|
||
return "INVALID_APPID", false, nil, nil
|
||
}
|
||
|
||
ts, _ := strconv.ParseInt(tsStr, 10, 64)
|
||
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
|
||
return "TIMESTAMP_EXPIRED", false, nil, nil
|
||
}
|
||
|
||
mac := hmac.New(sha256.New, []byte(appSecret))
|
||
mac.Write([]byte(appID + tsStr + httpReq.URL.Path))
|
||
expected := hex.EncodeToString(mac.Sum(nil))
|
||
|
||
if token != expected {
|
||
return "SIGNATURE_MISMATCH", false, nil, nil
|
||
}
|
||
return "OK", false, nil, nil
|
||
}
|
||
|
||
func (this *CheckpointHTTPDNSFingerprint) ResponseValue(
|
||
req CheckpointRequest, param string, options map[string]string, ruleId int64,
|
||
) (value any, hasRequestBody bool, sysErr error, userErr error) {
|
||
return "", false, nil, nil
|
||
}
|
||
|
||
func (this *CheckpointHTTPDNSFingerprint) getAppSecret(appID string) (string, error) {
|
||
client, err := rpc.SharedRPC()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
resp, err := client.HTTPDNSAppRPC.FindHTTPDNSAppSecret(
|
||
client.Context(), &pb.FindHTTPDNSAppSecretRequest{AppId: appID},
|
||
)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return resp.AppSecret, nil
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 3.3 Edge-API: App 管理服务
|
||
|
||
#### 3.3.1 数据库表
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS edgeHTTPDNSApps (
|
||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||
appId VARCHAR(64) NOT NULL UNIQUE COMMENT 'App标识',
|
||
appSecret VARCHAR(128) NOT NULL COMMENT 'App密钥',
|
||
name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '应用名称',
|
||
description TEXT COMMENT '描述',
|
||
userId BIGINT UNSIGNED DEFAULT 0 COMMENT '关联用户ID',
|
||
isOn TINYINT(1) DEFAULT 1 COMMENT '是否启用',
|
||
createdAt INT UNSIGNED DEFAULT 0,
|
||
state TINYINT(1) DEFAULT 1,
|
||
UNIQUE KEY uk_appId (appId),
|
||
KEY idx_userId (userId)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
```
|
||
|
||
#### 3.3.2 gRPC Proto
|
||
|
||
**文件**: `EdgeCommon/pkg/rpc/protos/service_httpdns_app.proto` (新建)
|
||
|
||
```protobuf
|
||
syntax = "proto3";
|
||
option go_package = "./pb";
|
||
package pb;
|
||
|
||
service HTTPDNSAppService {
|
||
rpc createHTTPDNSApp(CreateHTTPDNSAppRequest) returns (CreateHTTPDNSAppResponse);
|
||
rpc findHTTPDNSAppSecret(FindHTTPDNSAppSecretRequest) returns (FindHTTPDNSAppSecretResponse);
|
||
rpc listHTTPDNSApps(ListHTTPDNSAppsRequest) returns (ListHTTPDNSAppsResponse);
|
||
rpc deleteHTTPDNSApp(DeleteHTTPDNSAppRequest) returns (RPCSuccess);
|
||
}
|
||
|
||
message CreateHTTPDNSAppRequest {
|
||
string name = 1;
|
||
string description = 2;
|
||
int64 userId = 3;
|
||
}
|
||
message CreateHTTPDNSAppResponse {
|
||
int64 httpdnsAppId = 1;
|
||
string appId = 2;
|
||
string appSecret = 3;
|
||
}
|
||
message FindHTTPDNSAppSecretRequest {
|
||
string appId = 1;
|
||
}
|
||
message FindHTTPDNSAppSecretResponse {
|
||
string appSecret = 1;
|
||
}
|
||
message ListHTTPDNSAppsRequest {
|
||
int64 userId = 1;
|
||
int64 offset = 2;
|
||
int64 size = 3;
|
||
}
|
||
message ListHTTPDNSAppsResponse {
|
||
repeated HTTPDNSApp httpdnsApps = 1;
|
||
}
|
||
message DeleteHTTPDNSAppRequest {
|
||
int64 httpdnsAppId = 1;
|
||
}
|
||
message HTTPDNSApp {
|
||
int64 id = 1;
|
||
string appId = 2;
|
||
string appSecret = 3;
|
||
string name = 4;
|
||
int64 userId = 5;
|
||
bool isOn = 6;
|
||
int64 createdAt = 7;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 3.4 SDK 完整设计
|
||
|
||
#### 3.4.1 SDK 架构概览
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ HTTPDNS SDK │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
|
||
│ │ 解析引擎 │ │ 缓存管理 │ │ 网络感知 │ │
|
||
│ │ Resolver │ │ CacheManager │ │ NetworkMonitor│ │
|
||
│ └───────────────┘ └───────────────┘ └───────────────┘ │
|
||
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
|
||
│ │ 容错降级 │ │ 签名模块 │ │ 监控上报 │ │
|
||
│ │ Failover │ │ Signer │ │ Reporter │ │
|
||
│ └───────────────┘ └───────────────┘ └───────────────┘ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### 3.4.2 缓存管理模块 (CacheManager)
|
||
|
||
**设计原则**:
|
||
- 两级缓存:内存 + 持久化
|
||
- 软过期策略:过期后先返回旧数据,后台异步更新
|
||
- LRU 淘汰:防止内存溢出
|
||
|
||
**Android 实现**:
|
||
|
||
```kotlin
|
||
class HTTPDNSCacheManager(private val context: Context) {
|
||
// 内存缓存 (LRU)
|
||
private val memoryCache = object : LinkedHashMap<String, CacheEntry>(100, 0.75f, true) {
|
||
override fun removeEldestEntry(eldest: Map.Entry<String, CacheEntry>): Boolean {
|
||
return size > MAX_CACHE_SIZE
|
||
}
|
||
}
|
||
|
||
// 持久化缓存 (MMKV)
|
||
private val mmkv = MMKV.mmkvWithID("httpdns_cache", MMKV.MULTI_PROCESS_MODE)
|
||
|
||
companion object {
|
||
const val MAX_CACHE_SIZE = 100
|
||
const val SOFT_EXPIRE_SECONDS = 60 // 软过期:TTL 到期后仍可用 60s
|
||
}
|
||
|
||
data class CacheEntry(
|
||
val ips: List<String>,
|
||
val ipsV6: List<String>,
|
||
val expireAt: Long, // 硬过期时间
|
||
val softExpireAt: Long, // 软过期时间 = expireAt + SOFT_EXPIRE_SECONDS
|
||
val createAt: Long = System.currentTimeMillis()
|
||
) {
|
||
fun isExpired(): Boolean = System.currentTimeMillis() > expireAt
|
||
fun isSoftExpired(): Boolean = System.currentTimeMillis() > softExpireAt
|
||
fun toJson(): String = Gson().toJson(this)
|
||
}
|
||
|
||
/**
|
||
* 获取缓存(支持软过期)
|
||
* @return Pair<缓存结果, 是否需要后台刷新>
|
||
*/
|
||
@Synchronized
|
||
fun get(host: String): Pair<CacheEntry?, Boolean> {
|
||
// 1. 先查内存
|
||
memoryCache[host]?.let { entry ->
|
||
return when {
|
||
!entry.isExpired() -> Pair(entry, false) // 未过期
|
||
!entry.isSoftExpired() -> Pair(entry, true) // 软过期,需刷新
|
||
else -> Pair(null, true) // 完全过期
|
||
}
|
||
}
|
||
|
||
// 2. 查持久化
|
||
val json = mmkv.decodeString(host) ?: return Pair(null, true)
|
||
val entry = Gson().fromJson(json, CacheEntry::class.java)
|
||
|
||
// 回填内存
|
||
memoryCache[host] = entry
|
||
|
||
return when {
|
||
!entry.isExpired() -> Pair(entry, false)
|
||
!entry.isSoftExpired() -> Pair(entry, true)
|
||
else -> Pair(null, true)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 写入缓存
|
||
*/
|
||
@Synchronized
|
||
fun put(host: String, ips: List<String>, ipsV6: List<String>, ttl: Int) {
|
||
val now = System.currentTimeMillis()
|
||
val entry = CacheEntry(
|
||
ips = ips,
|
||
ipsV6 = ipsV6,
|
||
expireAt = now + ttl * 1000L,
|
||
softExpireAt = now + (ttl + SOFT_EXPIRE_SECONDS) * 1000L
|
||
)
|
||
memoryCache[host] = entry
|
||
mmkv.encode(host, entry.toJson())
|
||
}
|
||
|
||
/**
|
||
* 清空所有缓存(网络切换时调用)
|
||
*/
|
||
@Synchronized
|
||
fun clear() {
|
||
memoryCache.clear()
|
||
mmkv.clearAll()
|
||
}
|
||
}
|
||
```
|
||
|
||
**iOS 实现**:
|
||
|
||
```swift
|
||
class HTTPDNSCacheManager {
|
||
static let shared = HTTPDNSCacheManager()
|
||
|
||
private var memoryCache: [String: CacheEntry] = [:]
|
||
private let defaults = UserDefaults(suiteName: "com.goedge.httpdns")!
|
||
private let queue = DispatchQueue(label: "httpdns.cache", attributes: .concurrent)
|
||
|
||
private let maxCacheSize = 100
|
||
private let softExpireSeconds: TimeInterval = 60
|
||
|
||
struct CacheEntry: Codable {
|
||
let ips: [String]
|
||
let ipsV6: [String]
|
||
let expireAt: Date
|
||
let softExpireAt: Date
|
||
let createAt: Date
|
||
|
||
func isExpired() -> Bool { Date() > expireAt }
|
||
func isSoftExpired() -> Bool { Date() > softExpireAt }
|
||
}
|
||
|
||
func get(host: String) -> (entry: CacheEntry?, needRefresh: Bool) {
|
||
return queue.sync {
|
||
// 查内存
|
||
if let entry = memoryCache[host] {
|
||
if !entry.isExpired() { return (entry, false) }
|
||
if !entry.isSoftExpired() { return (entry, true) }
|
||
}
|
||
|
||
// 查持久化
|
||
guard let data = defaults.data(forKey: host),
|
||
let entry = try? JSONDecoder().decode(CacheEntry.self, from: data) else {
|
||
return (nil, true)
|
||
}
|
||
|
||
// 回填内存
|
||
memoryCache[host] = entry
|
||
|
||
if !entry.isExpired() { return (entry, false) }
|
||
if !entry.isSoftExpired() { return (entry, true) }
|
||
return (nil, true)
|
||
}
|
||
}
|
||
|
||
func put(host: String, ips: [String], ipsV6: [String], ttl: Int) {
|
||
queue.async(flags: .barrier) {
|
||
let entry = CacheEntry(
|
||
ips: ips,
|
||
ipsV6: ipsV6,
|
||
expireAt: Date().addingTimeInterval(TimeInterval(ttl)),
|
||
softExpireAt: Date().addingTimeInterval(TimeInterval(ttl) + self.softExpireSeconds),
|
||
createAt: Date()
|
||
)
|
||
self.memoryCache[host] = entry
|
||
|
||
// LRU 淘汰
|
||
if self.memoryCache.count > self.maxCacheSize {
|
||
let oldest = self.memoryCache.min { $0.value.createAt < $1.value.createAt }
|
||
if let key = oldest?.key { self.memoryCache.removeValue(forKey: key) }
|
||
}
|
||
|
||
// 持久化
|
||
if let data = try? JSONEncoder().encode(entry) {
|
||
self.defaults.set(data, forKey: host)
|
||
}
|
||
}
|
||
}
|
||
|
||
func clear() {
|
||
queue.async(flags: .barrier) {
|
||
self.memoryCache.removeAll()
|
||
// 清理持久化
|
||
for key in self.defaults.dictionaryRepresentation().keys {
|
||
self.defaults.removeObject(forKey: key)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.4.3 网络感知模块 (NetworkMonitor)
|
||
|
||
**功能**:
|
||
- 监听网络切换(WiFi ↔ 4G/5G)
|
||
- 网络切换时清空缓存
|
||
- 检测 IPv6 可用性
|
||
|
||
**Android 实现**:
|
||
|
||
```kotlin
|
||
class NetworkMonitor(private val context: Context) {
|
||
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||
private var lastNetworkType: String? = null
|
||
var onNetworkChanged: (() -> Unit)? = null
|
||
|
||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||
override fun onAvailable(network: Network) {
|
||
checkNetworkChange()
|
||
}
|
||
|
||
override fun onLost(network: Network) {
|
||
checkNetworkChange()
|
||
}
|
||
|
||
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
|
||
checkNetworkChange()
|
||
}
|
||
}
|
||
|
||
fun start() {
|
||
val request = NetworkRequest.Builder()
|
||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||
.build()
|
||
connectivityManager.registerNetworkCallback(request, networkCallback)
|
||
lastNetworkType = getCurrentNetworkType()
|
||
}
|
||
|
||
fun stop() {
|
||
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||
}
|
||
|
||
private fun checkNetworkChange() {
|
||
val currentType = getCurrentNetworkType()
|
||
if (currentType != lastNetworkType) {
|
||
Log.d("HTTPDNS", "Network changed: $lastNetworkType -> $currentType")
|
||
lastNetworkType = currentType
|
||
onNetworkChanged?.invoke()
|
||
}
|
||
}
|
||
|
||
private fun getCurrentNetworkType(): String {
|
||
val network = connectivityManager.activeNetwork ?: return "NONE"
|
||
val caps = connectivityManager.getNetworkCapabilities(network) ?: return "UNKNOWN"
|
||
return when {
|
||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "WIFI"
|
||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "CELLULAR"
|
||
else -> "OTHER"
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检测当前网络是否支持 IPv6
|
||
*/
|
||
fun isIPv6Supported(): Boolean {
|
||
return try {
|
||
val addresses = NetworkInterface.getNetworkInterfaces().toList()
|
||
.flatMap { it.inetAddresses.toList() }
|
||
addresses.any { it is Inet6Address && !it.isLoopbackAddress && !it.isLinkLocalAddress }
|
||
} catch (e: Exception) {
|
||
false
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**iOS 实现**:
|
||
|
||
```swift
|
||
import Network
|
||
|
||
class NetworkMonitor {
|
||
static let shared = NetworkMonitor()
|
||
|
||
private let monitor = NWPathMonitor()
|
||
private let queue = DispatchQueue(label: "httpdns.network")
|
||
private var lastInterfaceType: NWInterface.InterfaceType?
|
||
|
||
var onNetworkChanged: (() -> Void)?
|
||
|
||
func start() {
|
||
monitor.pathUpdateHandler = { [weak self] path in
|
||
let currentType = path.availableInterfaces.first?.type
|
||
if currentType != self?.lastInterfaceType {
|
||
print("HTTPDNS: Network changed \(self?.lastInterfaceType?.description ?? "nil") -> \(currentType?.description ?? "nil")")
|
||
self?.lastInterfaceType = currentType
|
||
DispatchQueue.main.async {
|
||
self?.onNetworkChanged?()
|
||
}
|
||
}
|
||
}
|
||
monitor.start(queue: queue)
|
||
}
|
||
|
||
func stop() {
|
||
monitor.cancel()
|
||
}
|
||
|
||
func isIPv6Supported() -> Bool {
|
||
let path = monitor.currentPath
|
||
return path.supportsIPv6
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.4.4 容错降级模块 (Failover)
|
||
|
||
**策略**:
|
||
1. 多节点轮询:主节点失败自动切备用
|
||
2. 超时降级:HTTPDNS 超时自动降级到系统 DNS
|
||
3. 黑名单机制:特定域名强制走系统 DNS
|
||
|
||
**Android 实现**:
|
||
|
||
```kotlin
|
||
class HTTPDNSResolver(
|
||
private val serverUrls: List<String>, // 多个服务节点
|
||
private val cacheManager: HTTPDNSCacheManager,
|
||
private val timeout: Long = 3000L
|
||
) {
|
||
private val blacklist = setOf("localhost", "*.local", "*.internal")
|
||
private var currentServerIndex = 0
|
||
|
||
suspend fun resolve(host: String): ResolveResult {
|
||
// 1. 黑名单检查
|
||
if (isBlacklisted(host)) {
|
||
return ResolveResult(systemResolve(host), ResolveSource.SYSTEM_DNS)
|
||
}
|
||
|
||
// 2. 查缓存
|
||
val (cached, needRefresh) = cacheManager.get(host)
|
||
if (cached != null) {
|
||
if (needRefresh) {
|
||
// 后台异步刷新
|
||
CoroutineScope(Dispatchers.IO).launch { fetchFromServer(host) }
|
||
}
|
||
return ResolveResult(cached.ips, ResolveSource.CACHE)
|
||
}
|
||
|
||
// 3. 请求服务器(带重试)
|
||
return try {
|
||
withTimeout(timeout) {
|
||
val ips = fetchFromServerWithRetry(host)
|
||
ResolveResult(ips, ResolveSource.HTTPDNS)
|
||
}
|
||
} catch (e: TimeoutCancellationException) {
|
||
// 4. 降级到系统 DNS
|
||
Log.w("HTTPDNS", "Timeout, fallback to system DNS")
|
||
ResolveResult(systemResolve(host), ResolveSource.SYSTEM_DNS)
|
||
}
|
||
}
|
||
|
||
private suspend fun fetchFromServerWithRetry(host: String): List<String> {
|
||
var lastError: Exception? = null
|
||
repeat(serverUrls.size) { attempt ->
|
||
try {
|
||
return fetchFromServer(host)
|
||
} catch (e: Exception) {
|
||
lastError = e
|
||
currentServerIndex = (currentServerIndex + 1) % serverUrls.size
|
||
Log.w("HTTPDNS", "Server ${serverUrls[currentServerIndex]} failed, trying next")
|
||
}
|
||
}
|
||
throw lastError ?: Exception("All servers failed")
|
||
}
|
||
|
||
private suspend fun fetchFromServer(host: String): List<String> {
|
||
val url = "${serverUrls[currentServerIndex]}/httpdns/resolve?host=$host"
|
||
val response = httpClient.get(url)
|
||
val result = parseResponse(response.body)
|
||
cacheManager.put(host, result.ips, result.ipsV6, result.ttl)
|
||
return result.ips
|
||
}
|
||
|
||
private fun systemResolve(host: String): List<String> {
|
||
return try {
|
||
InetAddress.getAllByName(host).map { it.hostAddress }
|
||
} catch (e: Exception) {
|
||
emptyList()
|
||
}
|
||
}
|
||
|
||
private fun isBlacklisted(host: String): Boolean {
|
||
return blacklist.any { pattern ->
|
||
if (pattern.startsWith("*")) {
|
||
host.endsWith(pattern.substring(1))
|
||
} else {
|
||
host == pattern
|
||
}
|
||
}
|
||
}
|
||
|
||
enum class ResolveSource { CACHE, HTTPDNS, SYSTEM_DNS }
|
||
data class ResolveResult(val ips: List<String>, val source: ResolveSource)
|
||
}
|
||
```
|
||
|
||
#### 3.4.5 监控上报模块 (Reporter)
|
||
|
||
**上报指标**:
|
||
- 解析成功率
|
||
- 解析耗时
|
||
- 缓存命中率
|
||
- 降级次数
|
||
|
||
**数据结构**:
|
||
|
||
```kotlin
|
||
data class HTTPDNSStats(
|
||
val host: String,
|
||
val resolveCount: Int,
|
||
val successCount: Int,
|
||
val cacheHitCount: Int,
|
||
val fallbackCount: Int,
|
||
val avgLatencyMs: Long,
|
||
val timestamp: Long
|
||
)
|
||
```
|
||
|
||
**Android 实现**:
|
||
|
||
```kotlin
|
||
class HTTPDNSReporter(private val reportUrl: String) {
|
||
private val stats = mutableMapOf<String, MutableStats>()
|
||
private val reportInterval = 60_000L // 60秒上报一次
|
||
|
||
data class MutableStats(
|
||
var resolveCount: Int = 0,
|
||
var successCount: Int = 0,
|
||
var cacheHitCount: Int = 0,
|
||
var fallbackCount: Int = 0,
|
||
var totalLatencyMs: Long = 0
|
||
)
|
||
|
||
fun recordResolve(host: String, source: ResolveSource, latencyMs: Long, success: Boolean) {
|
||
synchronized(stats) {
|
||
val s = stats.getOrPut(host) { MutableStats() }
|
||
s.resolveCount++
|
||
if (success) s.successCount++
|
||
s.totalLatencyMs += latencyMs
|
||
when (source) {
|
||
ResolveSource.CACHE -> s.cacheHitCount++
|
||
ResolveSource.SYSTEM_DNS -> s.fallbackCount++
|
||
else -> {}
|
||
}
|
||
}
|
||
}
|
||
|
||
fun startPeriodicReport() {
|
||
CoroutineScope(Dispatchers.IO).launch {
|
||
while (true) {
|
||
delay(reportInterval)
|
||
report()
|
||
}
|
||
}
|
||
}
|
||
|
||
private suspend fun report() {
|
||
val snapshot = synchronized(stats) {
|
||
val copy = stats.toMap()
|
||
stats.clear()
|
||
copy
|
||
}
|
||
|
||
if (snapshot.isEmpty()) return
|
||
|
||
val reports = snapshot.map { (host, s) ->
|
||
HTTPDNSStats(
|
||
host = host,
|
||
resolveCount = s.resolveCount,
|
||
successCount = s.successCount,
|
||
cacheHitCount = s.cacheHitCount,
|
||
fallbackCount = s.fallbackCount,
|
||
avgLatencyMs = if (s.resolveCount > 0) s.totalLatencyMs / s.resolveCount else 0,
|
||
timestamp = System.currentTimeMillis()
|
||
)
|
||
}
|
||
|
||
try {
|
||
httpClient.post(reportUrl) {
|
||
contentType(ContentType.Application.Json)
|
||
setBody(reports)
|
||
}
|
||
} catch (e: Exception) {
|
||
Log.e("HTTPDNS", "Report failed: ${e.message}")
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.4.6 完整 SDK 集成示例
|
||
|
||
**Android 初始化**:
|
||
|
||
```kotlin
|
||
class HTTPDNSManager private constructor(context: Context) {
|
||
private val cacheManager = HTTPDNSCacheManager(context)
|
||
private val networkMonitor = NetworkMonitor(context)
|
||
private val resolver = HTTPDNSResolver(
|
||
serverUrls = listOf(
|
||
"https://httpdns1.goedge.cn",
|
||
"https://httpdns2.goedge.cn"
|
||
),
|
||
cacheManager = cacheManager
|
||
)
|
||
private val signer = HTTPDNSSigner(appId = "ge_xxx", appSecret = "xxx")
|
||
private val reporter = HTTPDNSReporter("https://api.goedge.cn/httpdns/report")
|
||
|
||
companion object {
|
||
@Volatile
|
||
private var instance: HTTPDNSManager? = null
|
||
|
||
fun init(context: Context): HTTPDNSManager {
|
||
return instance ?: synchronized(this) {
|
||
instance ?: HTTPDNSManager(context.applicationContext).also { instance = it }
|
||
}
|
||
}
|
||
|
||
fun get(): HTTPDNSManager = instance ?: throw IllegalStateException("Must call init first")
|
||
}
|
||
|
||
init {
|
||
// 监听网络变化
|
||
networkMonitor.onNetworkChanged = {
|
||
Log.d("HTTPDNS", "Network changed, clearing cache")
|
||
cacheManager.clear()
|
||
}
|
||
networkMonitor.start()
|
||
|
||
// 启动监控上报
|
||
reporter.startPeriodicReport()
|
||
}
|
||
|
||
suspend fun resolve(host: String): List<String> {
|
||
val startTime = System.currentTimeMillis()
|
||
val result = resolver.resolve(host)
|
||
val latency = System.currentTimeMillis() - startTime
|
||
reporter.recordResolve(host, result.source, latency, result.ips.isNotEmpty())
|
||
return result.ips
|
||
}
|
||
|
||
fun signRequest(request: Request): Request = signer.sign(request)
|
||
|
||
/**
|
||
* 预解析核心域名(App 启动时调用)
|
||
*/
|
||
suspend fun prefetch(hosts: List<String>) {
|
||
hosts.forEach { host ->
|
||
launch { resolve(host) }
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**使用示例**:
|
||
|
||
```kotlin
|
||
// Application.onCreate
|
||
HTTPDNSManager.init(this)
|
||
|
||
// 预解析
|
||
lifecycleScope.launch {
|
||
HTTPDNSManager.get().prefetch(listOf(
|
||
"api.example.com",
|
||
"cdn.example.com",
|
||
"img.example.com"
|
||
))
|
||
}
|
||
|
||
// 业务请求
|
||
lifecycleScope.launch {
|
||
val ips = HTTPDNSManager.get().resolve("api.example.com")
|
||
if (ips.isNotEmpty()) {
|
||
val request = Request.Builder()
|
||
.url("https://${ips[0]}/v1/user")
|
||
.header("Host", "api.example.com")
|
||
.build()
|
||
val signedRequest = HTTPDNSManager.get().signRequest(request)
|
||
// 发起请求...
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 四、实施计划
|
||
|
||
### 4.1 任务清单
|
||
|
||
| 阶段 | 任务 | 工时 |
|
||
|------|------|------|
|
||
| **Phase 1** | 数据库 + DAO + gRPC | 3天 |
|
||
| **Phase 2** | Edge-DNS 接口 | 1天 |
|
||
| **Phase 3** | Edge-Node WAF | 1天 |
|
||
| **Phase 4** | Edge-Admin UI | 2天 |
|
||
| **Phase 5** | SDK 核心 (解析+签名) | 1天 |
|
||
| **Phase 6** | SDK 缓存模块 (内存+持久化+LRU+软过期) | 1天 |
|
||
| **Phase 7** | SDK 网络感知 (切换监听+IPv6检测) | 0.5天 |
|
||
| **Phase 8** | SDK 容错降级 (多节点重试+系统DNS降级) | 0.5天 |
|
||
| **Phase 9** | SDK 监控上报 | 0.5天 |
|
||
| **Phase 10** | SDK 集成测试 + 文档 | 0.5天 |
|
||
| **总计** | | **11天** |
|
||
|
||
### 4.2 文件变更清单
|
||
|
||
| 操作 | 文件路径 |
|
||
|------|----------|
|
||
| 新建 | `EdgeDNS/internal/nodes/httpdns.go` |
|
||
| 修改 | `EdgeDNS/internal/nodes/server.go` |
|
||
| 新建 | `EdgeNode/internal/waf/checkpoints/checkpoint_httpdns_fingerprint.go` |
|
||
| 修改 | `EdgeNode/internal/waf/checkpoints/init.go` |
|
||
| 新建 | `EdgeAPI/internal/db/models/httpdns_app_dao.go` |
|
||
| 新建 | `EdgeAPI/internal/rpc/services/service_httpdns_app.go` |
|
||
| 新建 | `EdgeCommon/pkg/rpc/protos/service_httpdns_app.proto` |
|
||
| 新建 | `EdgeAdmin/internal/web/actions/httpdns/*.go` |
|
||
| 新建 | `EdgeAdmin/web/views/httpdns/apps/*.html` |
|
||
|
||
---
|
||
|
||
## 五、测试验证
|
||
|
||
### 5.1 服务端测试
|
||
|
||
```bash
|
||
# 1. 测试 HTTPDNS 解析
|
||
curl "https://httpdns.example.com/httpdns/resolve?host=api.example.com"
|
||
|
||
# 2. 测试无签名访问业务(应被拦截)
|
||
curl "https://1.2.3.4/v1/user" -H "Host: api.example.com"
|
||
|
||
# 3. 测试带签名访问
|
||
curl "https://1.2.3.4/v1/user" \
|
||
-H "Host: api.example.com" \
|
||
-H "X-GE-AppID: ge_abc123" \
|
||
-H "X-GE-Timestamp: 1700000000" \
|
||
-H "X-GE-Token: <valid_signature>"
|
||
```
|
||
|
||
### 5.2 SDK 测试矩阵
|
||
|
||
| 模块 | 测试场景 | 预期结果 |
|
||
|------|----------|----------|
|
||
| **缓存** | 首次解析 | 请求服务器,写入缓存 |
|
||
| **缓存** | 缓存有效期内再次解析 | 直接返回缓存,不发请求 |
|
||
| **缓存** | 缓存软过期 | 返回旧数据,后台异步刷新 |
|
||
| **缓存** | 缓存完全过期 | 重新请求服务器 |
|
||
| **缓存** | 缓存超过100条 | LRU 淘汰最旧条目 |
|
||
| **网络感知** | WiFi → 4G 切换 | 清空所有缓存 |
|
||
| **网络感知** | 4G → WiFi 切换 | 清空所有缓存 |
|
||
| **网络感知** | IPv6 检测(双栈网络) | 返回 true |
|
||
| **容错** | 主节点超时 | 自动切换备用节点 |
|
||
| **容错** | 所有节点失败 | 降级到系统 DNS |
|
||
| **容错** | 黑名单域名 | 直接走系统 DNS |
|
||
| **监控** | 60 秒内多次解析 | 统计聚合后上报 |
|
||
| **监控** | 上报失败 | 静默失败,不影响解析 |
|
||
|
||
### 5.3 Android 单元测试示例
|
||
|
||
```kotlin
|
||
@Test
|
||
fun `cache hit returns immediately without network request`() = runTest {
|
||
// Given
|
||
cacheManager.put("api.example.com", listOf("1.2.3.4"), emptyList(), 600)
|
||
|
||
// When
|
||
val result = resolver.resolve("api.example.com")
|
||
|
||
// Then
|
||
assertEquals(listOf("1.2.3.4"), result.ips)
|
||
assertEquals(ResolveSource.CACHE, result.source)
|
||
verify(httpClient, never()).get(any())
|
||
}
|
||
|
||
@Test
|
||
fun `soft expired cache triggers background refresh`() = runTest {
|
||
// Given: cache with TTL=1s, soft expire = TTL+60s
|
||
cacheManager.put("api.example.com", listOf("1.2.3.4"), emptyList(), 1)
|
||
advanceTimeBy(2000) // TTL expired but within soft expire
|
||
|
||
// When
|
||
val result = resolver.resolve("api.example.com")
|
||
|
||
// Then
|
||
assertEquals(listOf("1.2.3.4"), result.ips) // Returns stale data
|
||
advanceUntilIdle()
|
||
verify(httpClient).get(any()) // Background refresh triggered
|
||
}
|
||
|
||
@Test
|
||
fun `network change clears cache`() = runTest {
|
||
// Given
|
||
cacheManager.put("api.example.com", listOf("1.2.3.4"), emptyList(), 600)
|
||
|
||
// When
|
||
networkMonitor.simulateNetworkChange()
|
||
|
||
// Then
|
||
val (cached, _) = cacheManager.get("api.example.com")
|
||
assertNull(cached)
|
||
}
|
||
|
||
@Test
|
||
fun `fallback to system DNS on timeout`() = runTest {
|
||
// Given
|
||
whenever(httpClient.get(any())).thenThrow(TimeoutException())
|
||
|
||
// When
|
||
val result = resolver.resolve("api.example.com")
|
||
|
||
// Then
|
||
assertEquals(ResolveSource.SYSTEM_DNS, result.source)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 六、上线清单
|
||
|
||
### 6.1 服务端部署
|
||
|
||
- [ ] 数据库迁移(edgeHTTPDNSApps 表)
|
||
- [ ] Edge-API 部署
|
||
- [ ] Edge-DNS 部署
|
||
- [ ] Edge-Node 部署(含 WAF 指纹校验)
|
||
- [ ] Edge-Admin 部署(App 管理 UI)
|
||
- [ ] 创建测试 App(获取 AppID/Secret)
|
||
|
||
### 6.2 SDK 发布
|
||
|
||
- [ ] Android SDK 单元测试通过
|
||
- [ ] iOS SDK 单元测试通过
|
||
- [ ] Android SDK 集成测试(真机)
|
||
- [ ] iOS SDK 集成测试(真机)
|
||
- [ ] SDK 打包发布(Maven/CocoaPods)
|
||
- [ ] SDK 接入文档发布
|
||
|
||
### 6.3 SDK 功能验收
|
||
|
||
- [ ] 缓存命中验证
|
||
- [ ] 软过期刷新验证
|
||
- [ ] LRU 淘汰验证
|
||
- [ ] 网络切换清缓存验证
|
||
- [ ] 多节点切换验证
|
||
- [ ] 系统 DNS 降级验证
|
||
- [ ] 监控数据上报验证
|
||
- [ ] 预解析功能验证
|
||
|