[AI生成] go实现dns缓存

基于net.DefaultResolver的自定义拨号器,默认优先走dns解析(超时时间1s),dns解析失败时走缓存,缓存有效期是24小时;支持指定哪些域名提前预解析。

image

options.go

// Package dnsplugin 提供基于 DNS 缓存的域名解析服务
// 参考自 volcengine/dns-stale-cache 的设计思想
package dnsplugin

import "time"

// staleUpTo 是缓存的统一过期时间(与官方一致,24 小时)
// 所有缓存条目在存储 24 小时后会被自动清理
const staleUpTo = 24 * time.Hour

// Options 定义 DNS 解析器的配置项(与 volcengine/dns-stale-cache 对齐)
type Options struct {
	CacheFirst bool          // 是否优先使用缓存:true=先查缓存再异步刷新,false=每次都查 DNS 然后刷新缓存
	DnsTimeout time.Duration // DNS 查询超时时间(默认 1 秒)
	Addr       []string      // 需要解析的域名或 host:port 地址列表(可选)
}

// Option 是函数式选项类型,用于灵活配置 Options
type Option func(*Options)

// WithCacheFirst 设置是否优先使用缓存(与 volcengine/dns-stale-cache 语义一致)
//   - false(默认):每次先发起 DNS 请求,拿到结果后刷新缓存(保证最新)
//   - true:每次先返回缓存内容,异步发起 DNS 请求刷新缓存(性能优先)
func WithCacheFirst(preferUse bool) Option {
	return func(o *Options) {
		o.CacheFirst = preferUse
	}
}

// WithDnsTimeout 设置 DNS 查询的超时时间(与 volcengine/dns-stale-cache 语义一致)
// 默认值为 1 秒
func WithDnsTimeout(timeout time.Duration) Option {
	return func(o *Options) {
		o.DnsTimeout = timeout
	}
}

// WithAddr 设置需要解析的域名或 host:port 地址列表(与 volcengine/dns-stale-cache 语义一致)
// 适用于预先知道要解析哪些域名的场景
func WithAddr(addr []string) Option {
	return func(o *Options) {
		o.Addr = addr
	}
}

// defaultOptions 返回默认配置(与 volcengine/dns-stale-cache 保持一致)
//   - CacheFirst: false(默认先查 DNS)
//   - DnsTimeout: 1 second(默认 1 秒超时)
//   - 缓存统一 24 小时过期(staleUpTo 常量)
func defaultOptions() *Options {
	return &Options{
		CacheFirst: false,           // 默认 false
		DnsTimeout: 1 * time.Second, // 默认 1 秒
		Addr:       nil,             // 默认无地址
	}
}

dialer.go

package dnsplugin

import (
	"context"
	"fmt"
	"net"
	"time"
)

// Dialer 是带 DNS 缓存的自定义拨号器
// 让上层的 HTTP Client、gRPC Client 等自动使用 DNS 缓存
//
// 使用方式(以标准库 HTTP 为例):
//
//	dialer := NewDialer(WithCacheFirst(true))
//	transport := &http.Transport{
//	    DialContext: dialer.DialContext,
//	}
//	client := &http.Client{Transport: transport}
//
// 完整调用链:
//
//	client.Get("https://github.com")
//	    → http.Transport 建立 TCP 连接
//	    → dialer.DialContext("tcp", "github.com:443")
//	    → resolver.ResolveFirst("github.com") 解析为 IP(优先查缓存)
//	    → 用解析后的 IP 和 net.Dialer 建立 TCP 连接
type Dialer struct {
	resolver  *Resolver
	netDialer *net.Dialer
}

// NewDialer 创建一个默认的带 DNS 缓存的拨号器
// 接受与 NewResolver 相同的 Option 配置
func NewDialer(opts ...Option) *Dialer {
	return &Dialer{
		resolver:  NewResolver(opts...),
		netDialer: &net.Dialer{Timeout: 30 * time.Second},
	}
}

// NewDialerWithBase 基于用户提供的 net.Dialer 创建拨号器
// 适用场景:已有自定义的 net.Dialer(设置了 KeepAlive、LocalAddr 等)
func NewDialerWithBase(baseDialer *net.Dialer, opts ...Option) *Dialer {
	if baseDialer == nil {
		baseDialer = &net.Dialer{Timeout: 30 * time.Second}
	}
	return &Dialer{
		resolver:  NewResolver(opts...),
		netDialer: baseDialer,
	}
}

// Dial 不带 context 的拨号方法(兼容旧代码)
// 内部转发到 DialContext,使用 Background context
func (d *Dialer) Dial(network, address string) (net.Conn, error) {
	return d.DialContext(context.Background(), network, address)
}

// DialContext 带 context 的拨号方法(http.Transport 会调用它)
// 执行流程:
//  1. 拆分 address 为 host 和 port
//  2. 如果 host 是纯 IP,直接用底层 netDialer 拨号
//  3. 否则用带缓存的 Resolver 解析 host,得到一个 IP
//  4. 用解析后的 IP 和 port 拼接成新地址,调用底层 netDialer 建立 TCP 连接
func (d *Dialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
	// 步骤 1:拆分 host 和 port
	host, port, err := net.SplitHostPort(address)
	if err != nil {
		return nil, fmt.Errorf("invalid address %s: %w", address, err)
	}

	// 步骤 2:纯 IP 地址直接拨号(无需 DNS 解析)
	if net.ParseIP(host) != nil {
		return d.netDialer.DialContext(ctx, network, address)
	}

	// 步骤 3:用带缓存的 Resolver 解析域名,得到第一个 IP
	resolvedIP, err := d.resolver.ResolveFirst(host)
	if err != nil {
		return nil, fmt.Errorf("DNS resolve failed for %s: %w", host, err)
	}

	// 步骤 4:拼接成 IP:port,用底层 netDialer 建立 TCP 连接
	resolvedAddr := net.JoinHostPort(resolvedIP, port)
	return d.netDialer.DialContext(ctx, network, resolvedAddr)
}

// GetResolver 返回内部的 Resolver(用于调试或直接调用解析方法)
func (d *Dialer) GetResolver() *Resolver {
	return d.resolver
}

// ClearCache 清空 DNS 缓存(透传到底层 Resolver)
func (d *Dialer) ClearCache() {
	d.resolver.ClearCache()
}

resolver.go

package dnsplugin

import (
	"context"
	"fmt"
	"net"
	"strings"
	"time"
)

// Resolver 是 DNS 域名解析器(与 volcengine/dns-stale-cache 对齐)
// 在系统默认 DNS 解析器之上封装了一层 24 小时缓存
// 核心功能:
//  1. 将域名解析为 IP 列表
//  2. 对解析结果进行缓存(统一 24 小时过期)
//  3. 对 IP 地址直接返回,不做 DNS 查询
//  4. 支持 CacheFirst 模式:先查缓存再异步刷新
type Resolver struct {
	cache *dnsCache // DNS 缓存(24 小时过期)
	opts  *Options  // 配置选项
}

// NewResolver 创建一个新的 DNS 解析器
// 接受可变数量的 Option 函数,用于覆盖默认配置
// 用法:
//
//	resolver := NewResolver(
//	    WithCacheFirst(true),
//	    WithDnsTimeout(2*time.Second),
//	)
func NewResolver(opts ...Option) *Resolver {
	options := defaultOptions()
	for _, opt := range opts {
		opt(options)
	}

	return &Resolver{
		cache: newDNSCache(),
		opts:  options,
	}
}

// Resolve 解析单个域名(或 host:port 字符串)为 IP 列表
// 这是最基础的解析方法,流程:
//  1. 如果输入包含端口号,先拆分出纯域名
//  2. 如果输入本身是纯 IP,直接返回
//  3. CacheFirst=true: 先查缓存 → 命中则异步刷新 DNS 后返回缓存
//  4. CacheFirst=false: 先查 DNS → 成功则刷新缓存后返回
//  5. DNS 失败:如果缓存中有,则返回缓存(降级使用)
func (r *Resolver) Resolve(hostPort string) ([]string, error) {
	// 步骤 1:拆分 host:port
	host, port, err := net.SplitHostPort(hostPort)
	if err != nil {
		// 拆分失败,说明是纯域名(无端口)
		host = hostPort
	}

	// 步骤 2:纯 IP 直接返回
	if net.ParseIP(host) != nil {
		// 如果有 port,则返回 IP:port 格式
		if port != "" {
			return []string{net.JoinHostPort(host, port)}, nil
		}
		return []string{host}, nil
	}

	// 步骤 3:CacheFirst=true → 先查缓存
	if r.opts.CacheFirst {
		if ips, ok := r.cache.get(host); ok {
			// 命中缓存:先返回,再异步发起 DNS 查询刷新缓存
			go r.requestAndRefresh(host) // 异步刷新,不阻塞当前调用
			// 如果有 port,则给每个 IP 都加上 port
			if port != "" {
				result := make([]string, len(ips))
				for i, ip := range ips {
					result[i] = net.JoinHostPort(ip, port)
				}
				return result, nil
			}
			return ips, nil
		}
	}

	// 步骤 4:发起 DNS 查询(带超时)
	ips, err := r.lookupDNS(host)
	if err != nil {
		// DNS 失败:如果缓存中有,降级使用缓存
		if cachedIPs, ok := r.cache.get(host); ok {
			if port != "" {
				result := make([]string, len(cachedIPs))
				for i, ip := range cachedIPs {
					result[i] = net.JoinHostPort(ip, port)
				}
				return result, nil
			}
			return cachedIPs, nil
		}
		return nil, err // 没有缓存,返回 DNS 错误
	}

	// 步骤 5:DNS 成功,写入缓存并返回
	r.cache.set(host, ips)
	if port != "" {
		result := make([]string, len(ips))
		for i, ip := range ips {
			result[i] = net.JoinHostPort(ip, port)
		}
		return result, nil
	}
	return ips, nil
}

// LookupHost 解析配置的地址列表(与 volcengine/dns-stale-cache 对齐)
// 遍历 opts.Addr 中的每个地址,依次解析并返回结果列表
// 每个结果以逗号分隔的 IP(或 IP:port)字符串形式返回
func (r *Resolver) LookupHost() ([]string, error) {
	var result []string
	for _, addr := range r.opts.Addr {
		// 处理 URL 格式(例如 "https://github.com")
		if strings.Contains(addr, "//") {
			// 简单处理:去掉 scheme 部分
			if idx := strings.Index(addr, "//"); idx >= 0 {
				addr = addr[idx+2:]
			}
		}

		// 解析该地址
		ips, err := r.Resolve(addr)
		if err != nil {
			continue // 解析失败的跳过(与官方行为一致)
		}
		// 将 IP 列表以逗号拼接,加入结果
		result = append(result, strings.Join(ips, ","))
	}
	return result, nil
}

// ResolveFirst 解析域名并返回第一个 IP(适用于只需一个 IP 的场景)
func (r *Resolver) ResolveFirst(hostPort string) (string, error) {
	ips, err := r.Resolve(hostPort)
	if err != nil {
		return "", err
	}
	if len(ips) == 0 {
		return "", fmt.Errorf("no IPs resolved for %s", hostPort)
	}
	return ips[0], nil // 返回第一个 IP(与官方一致,无轮询)
}

// requestAndRefresh 异步发起 DNS 查询并刷新缓存
// 用于 CacheFirst=true 模式下:已命中缓存但需要后台刷新
func (r *Resolver) requestAndRefresh(host string) {
	ips, err := r.lookupDNS(host)
	if err != nil {
		return // DNS 查询失败,保持原有缓存不变
	}
	r.cache.set(host, ips) // 刷新缓存
}

// lookupDNS 执行实际的 DNS 查询(内部方法)
// 使用系统默认的 Resolver,带 context 超时控制
func (r *Resolver) lookupDNS(host string) ([]string, error) {
	ctx, cancel := context.WithTimeout(context.Background(), r.opts.DnsTimeout)
	defer cancel()

	ips, err := net.DefaultResolver.LookupHost(ctx, host)
	if err != nil {
		return nil, err
	}
	if len(ips) == 0 {
		return nil, fmt.Errorf("no IPs found for %s", host)
	}
	return ips, nil
}

// ClearCache 清空所有缓存
func (r *Resolver) ClearCache() {
	r.cache.clear()
}

// CleanExpired 清理过期缓存(超过 24 小时未刷新的条目)
func (r *Resolver) CleanExpired() {
	r.cache.cleanExpired()
}

// Stats 返回当前缓存的统计信息(用于调试和监控)
func (r *Resolver) Stats() string {
	r.cache.mu.RLock()
	defer r.cache.mu.RUnlock()
	var sb strings.Builder
	sb.WriteString(fmt.Sprintf("DNS Cache Stats: %d entries\n", len(r.cache.items)))
	for host, entry := range r.cache.items {
		elapsed := time.Since(entry.Stored)
		remaining := staleUpTo - elapsed
		if remaining < 0 {
			remaining = 0
		}
		sb.WriteString(fmt.Sprintf("  %s -> %v (stored %v ago, expires in %v)\n",
			host, entry.IPs, elapsed.Round(time.Second), remaining.Round(time.Second)))
	}
	return sb.String()
}

cache.go

package dnsplugin

import (
	"sync"
	"time"
)

// cacheEntry 表示单个域名的缓存条目(与 volcengine 官方一致的简化结构)
// 只存储 IP 列表和存储时间戳,缓存统一 24 小时过期
type cacheEntry struct {
	IPs    []string  // 解析得到的 IP 列表
	Stored time.Time // 缓存存储时间(用于判断是否超过 24 小时)
}

// dnsCache 是线程安全的 DNS 缓存结构(与 volcengine 官方一致的简化实现)
// 使用 map 存储,sync.RWMutex 保证并发安全
type dnsCache struct {
	mu    sync.RWMutex
	items map[string]*cacheEntry
}

// newDNSCache 创建并初始化一个新的 DNS 缓存
func newDNSCache() *dnsCache {
	return &dnsCache{
		items: make(map[string]*cacheEntry),
	}
}

// get 从缓存中查询域名对应的 IP 列表
// 缓存有效期为 24 小时(staleUpTo),超过则视为失效
// 返回值:IP 列表, 是否命中缓存
func (c *dnsCache) get(host string) ([]string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()

	entry, ok := c.items[host]
	if !ok {
		return nil, false // 未命中缓存
	}

	// 判断是否已过 24 小时有效期
	// time.Since(entry.Stored) 返回从存储到现在经过的时间
	// 超过 staleUpTo(24 小时)则视为缓存失效
	if time.Since(entry.Stored) >= staleUpTo {
		return nil, false // 缓存已过期
	}

	return entry.IPs, true // 命中有效缓存
}

// set 将域名解析结果写入缓存
// 记录存储时间,24 小时后自动失效
func (c *dnsCache) set(host string, ips []string) {
	c.mu.Lock()
	defer c.mu.Unlock()

	c.items[host] = &cacheEntry{
		IPs:    ips,
		Stored: time.Now(), // 记录当前时间为存储时间
	}
}

// clear 清空所有缓存
func (c *dnsCache) clear() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.items = make(map[string]*cacheEntry)
}

// cleanExpired 清理所有过期的缓存条目(超过 24 小时)
// 类似官方的 cleanFileCache,但仅清理内存缓存
func (c *dnsCache) cleanExpired() {
	c.mu.Lock()
	defer c.mu.Unlock()

	for host, entry := range c.items {
		if time.Since(entry.Stored) >= staleUpTo {
			delete(c.items, host) // 移除过期条目
		}
	}
}

// size 返回当前缓存中的有效条目数
func (c *dnsCache) size() int {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return len(c.items)
}

main.go

// Package main 是 dns-plugin 的演示程序(使用 default 配置,无自定义)
// 按顺序展示 3 个核心功能:
//  1. 基础 DNS 解析(域名 → IP,含 LookupHost 批量解析)
//  2. 缓存命中对比(第一次 DNS 查询 vs 第二次缓存读取)
//  3. 自定义 Dialer 集成到 HTTP Client
package main

import (
	"fmt"
	"io"
	"net/http"
	"time"

	dnsplugin "test/dns-plugin"
)

func main() {
	fmt.Println("========================================")
	fmt.Println("  DNS Plugin Demo - 使用 Default 配置")
	fmt.Println("========================================")
	fmt.Println()

	// 演示 1:基础 DNS 解析
	demoBasicResolve()
	fmt.Println()

	// 演示 2:缓存命中对比
	demoCacheHit()
	fmt.Println()

	// 演示 3:自定义 Dialer + HTTP Client
	demoDialerHTTP()
	fmt.Println()

	fmt.Println("========================================")
	fmt.Println("  Demo Complete - 所有演示完成")
	fmt.Println("========================================")
}

// ============================================================================
// 演示 1:基础 DNS 解析(default 配置)
// ============================================================================
// 使用 NewResolver() 无参数(即 default 配置):
//   - CacheFirst: false(每次先发起 DNS 查询,然后刷新缓存)
//   - DnsTimeout: 1 秒
//   - 缓存 24 小时过期
func demoBasicResolve() {
	fmt.Println("--- 演示 1:基础 DNS 解析 (default 配置) ---")

	// 使用 default 配置创建 Resolver(无需传任何 Option)
	resolver := dnsplugin.NewResolver()

	// 测试 3 个主机名:两个国内真实域名 + 一个纯 IP
	hosts := []string{"www.baidu.com", "www.aliyun.com", "192.168.1.1"}
	for _, host := range hosts {
		ips, err := resolver.Resolve(host)
		if err != nil {
			fmt.Printf("  %s -> 解析失败: %v\n", host, err)
		} else {
			fmt.Printf("  %s -> %v\n", host, ips)
		}
	}

	// 演示 LookupHost 批量解析:需要预配置 Addr(这是唯一需要传 Option 的场景)
	fmt.Println()
	fmt.Println("  --- 批量解析 (LookupHost) ---")
	resolver2 := dnsplugin.NewResolver(
		dnsplugin.WithAddr([]string{"www.baidu.com:443", "www.aliyun.com:443"}),
	)
	results, err := resolver2.LookupHost()
	if err != nil {
		fmt.Printf("  批量解析失败: %v\n", err)
	} else {
		for _, r := range results {
			fmt.Printf("  -> %s\n", r)
		}
	}

	// 打印缓存统计
	fmt.Println()
	fmt.Println(resolver2.Stats())
}

// ============================================================================
// 演示 2:缓存命中演示(CacheFirst=true)
// ============================================================================
// 演示 CacheFirst=true 下的缓存行为:
//   - 第一次:缓存为空 → 发起 DNS 查询 → 写入缓存 → 返回
//   - 第二次:先查缓存 → 命中 → 直接返回(同时后台异步刷新)
//   - DNS 失败时:降级使用缓存
//
// 核心价值:
//  1. 加速:后续请求直接命中缓存(微秒级 vs 毫秒级)
//  2. 高可用:DNS 服务器故障时仍可用缓存返回
//  3. 新鲜度:命中缓存后异步发起 DNS 查询刷新缓存,保证数据不过时
func demoCacheHit() {
	fmt.Println("--- 演示 2:缓存命中对比 (CacheFirst=true) ---")

	// 显式设置 CacheFirst=true:优先查缓存
	resolver := dnsplugin.NewResolver(
		dnsplugin.WithCacheFirst(true),
	)

	host := "www.baidu.com"

	// 第一次解析:缓存为空 → 必须发起真实 DNS 查询
	fmt.Printf("  第一次解析 %s(缓存为空,发起 DNS 查询)...\n", host)
	start := time.Now()
	ips1, err := resolver.Resolve(host)
	elapsed1 := time.Since(start)
	if err != nil {
		fmt.Printf("  错误: %v\n", err)
		return
	}
	fmt.Printf("  结果: %v, 耗时: %v\n", ips1, elapsed1)

	// 第二次解析:命中我们自己的缓存 → 直接返回
	// 同时后台会异步发起 DNS 查询刷新缓存
	fmt.Printf("  第二次解析(命中缓存,异步刷新)...\n")
	start = time.Now()
	ips2, err := resolver.Resolve(host)
	elapsed2 := time.Since(start)
	if err != nil {
		fmt.Printf("  错误: %v\n", err)
		return
	}
	fmt.Printf("  结果: %v, 耗时: %v\n", ips2, elapsed2)

	// 计算加速倍数(分母加 1 避免除以 0)
	speedup := float64(elapsed1) / float64(elapsed2+1)
	fmt.Printf("  缓存加速: ~%.1fx 倍\n", speedup)

	fmt.Println()
	fmt.Println("  说明:CacheFirst=true 时,第二次从我们的缓存直接返回。")
	fmt.Println("  注:第1次如果也很快(<1ms),可能命中了操作系统 DNS 缓存。")
	fmt.Println("  缓存统一 24 小时过期,可通过 CleanExpired() 主动清理。")
	fmt.Println()
	fmt.Println(resolver.Stats())
}

// ============================================================================
// 演示 3:自定义 Dialer + HTTP Client(default 配置)
// ============================================================================
// 展示如何将 DNS Plugin 集成到标准库 net/http 中
// 使用 NewDialer() 无参数(default 配置)
func demoDialerHTTP() {
	fmt.Println("--- 演示 3:自定义 Dialer + HTTP Client (default 配置) ---")

	// 使用 default 配置创建 Dialer(无需传任何 Option)
	dialer := dnsplugin.NewDialer()

	// 创建 http.Transport,替换默认的 DialContext
	transport := &http.Transport{
		DialContext: dialer.DialContext,
	}

	// 创建 HTTP Client
	client := &http.Client{
		Transport: transport,
		Timeout:   10 * time.Second,
	}

	// 发送 HTTP 请求,内部会自动使用 DNS 缓存
	urls := []string{"https://www.baidu.com"}
	for _, url := range urls {
		fmt.Printf("  GET %s...\n", url)
		start := time.Now()
		resp, err := client.Get(url)
		elapsed := time.Since(start)
		if err != nil {
			fmt.Printf("  错误: %v\n", err)
			continue
		}

		bodySize, _ := io.Copy(io.Discard, resp.Body)
		resp.Body.Close()

		fmt.Printf("  状态: %s, 响应大小: %d bytes, 耗时: %v\n",
			resp.Status, bodySize, elapsed)
	}

	// 打印 DNS 缓存统计
	fmt.Println()
	fmt.Println(dialer.GetResolver().Stats())
}

运行结果

$ go run main.go
========================================
  DNS Plugin Demo - 使用 Default 配置
========================================

--- 演示 1:基础 DNS 解析 (default 配置) ---
  www.baidu.com -> [180.101.49.44 180.101.51.73]
  www.aliyun.com -> [122.228.195.21 122.225.209.41 115.238.197.52 122.228.79.44 122.228.15.36 61.164.147.227 60.188.126.94 61.164.115.95]
  192.168.1.1 -> [192.168.1.1]

  --- 批量解析 (LookupHost) ---
  -> 180.101.49.44:443,180.101.51.73:443
  -> 122.228.195.21:443,122.225.209.41:443,115.238.197.52:443,122.228.79.44:443,122.228.15.36:443,61.164.147.227:443,60.188.126.94:443,61.164.115.95:443

DNS Cache Stats: 2 entries
  www.baidu.com -> [180.101.49.44 180.101.51.73] (stored 0s ago, expires in 24h0m0s)
  www.aliyun.com -> [122.228.195.21 122.225.209.41 115.238.197.52 122.228.79.44 122.228.15.36 61.164.147.227 60.188.126.94 61.164.115.95] (stored 0s ago, expires in 24h0m0s)


--- 演示 2:缓存命中对比 (CacheFirst=true) ---
  第一次解析 www.baidu.com(缓存为空,发起 DNS 查询)...
  结果: [180.101.49.44 180.101.51.73], 耗时: 540.7µs
  第二次解析(命中缓存,异步刷新)...
  结果: [180.101.49.44 180.101.51.73], 耗时: 0s
  缓存加速: ~540700.0x 倍

  说明:CacheFirst=true 时,第二次从我们的缓存直接返回。
  注:第1次如果也很快(<1ms),可能命中了操作系统 DNS 缓存。
  缓存统一 24 小时过期,可通过 CleanExpired() 主动清理。

DNS Cache Stats: 1 entries
  www.baidu.com -> [180.101.49.44 180.101.51.73] (stored 0s ago, expires in 24h0m0s)


--- 演示 3:自定义 Dialer + HTTP Client (default 配置) ---
  GET https://www.baidu.com...
  状态: 200 OK, 响应大小: 2443 bytes, 耗时: 118.2591ms

DNS Cache Stats: 1 entries
  www.baidu.com -> [180.101.49.44 180.101.51.73] (stored 0s ago, expires in 24h0m0s)


========================================
  Demo Complete - 所有演示完成
========================================

 

posted on 2026-06-11 21:41  王景迁  阅读(2)  评论(0)    收藏  举报

导航