Files
tamakyi d690355cdc feat(honeypot): 蜜罐安全状况周期汇总(周报/月报 + 地图 + 邮件)
以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。

新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
  答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
  「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
  (一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
  才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
  钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
  每次运行精修到不同 IP,配额花在长尾上。

报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
  相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
  路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
  必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
  标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
  看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
  代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。

存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
  失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
  defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)

配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。

顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
  TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
2026-10-30 16:40:44 +08:00

501 lines
18 KiB
Go

// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package geoip
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// rewriteTransport 把真实域名的请求改投到本地测试服务器。
//
// 用改投而不是给生产代码加一个 baseURL 字段:生产代码里不该为了测试多出
// 「可以被指向别处」的开关,那类开关一旦被配置错就是把攻击者 IP 发到未知主机。
type rewriteTransport struct {
target *url.URL
}
func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
next := req.Clone(req.Context())
next.URL.Scheme = t.target.Scheme
next.URL.Host = t.target.Host
return http.DefaultTransport.RoundTrip(next)
}
// newTestClient 返回一个把所有出站请求改投到 srv 的 client。
func newTestClient(t *testing.T, srv *httptest.Server) *http.Client {
t.Helper()
u, err := url.Parse(srv.URL)
require.NoError(t, err)
return &http.Client{Transport: &rewriteTransport{target: u}}
}
// ipAPIServer 起一个假的 ip-api 批量接口。
//
// respond 收到本批请求的地址列表,返回对应的 ipAPIResult 切片;
// status 非 0 时直接返回该 HTTP 状态码(用于模拟网关故障)。
func ipAPIServer(t *testing.T, respond func(addrs []string) ([]ipAPIResult, int)) (*httptest.Server, *int32) {
t.Helper()
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/batch" {
http.NotFound(w, req)
return
}
if req.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
atomic.AddInt32(&calls, 1)
var addrs []string
if err := json.NewDecoder(req.Body).Decode(&addrs); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
results, code := respond(addrs)
if code != 0 {
http.Error(w, "upstream failure", code)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(results)
}))
t.Cleanup(srv.Close)
return srv, &calls
}
// okIP 构造一条成功的 ip-api 结果。
func okIP(addr, country, code, city string, lat, lon float64, isp, as string) ipAPIResult {
return ipAPIResult{
Query: addr, Status: "success", Country: country, CountryCode: code,
City: city, Lat: lat, Lon: lon, ISP: isp, AS: as,
}
}
// TestLookupSuccess 三态里的「查到了」与「服务明确说没有」。
func TestLookupSuccess(t *testing.T) {
srv, calls := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) {
return []ipAPIResult{
okIP(addrs[0], "德国", "DE", "Berlin", 52.52, 13.4, "Hetzner", "AS24940 Hetzner Online GmbH"),
{Query: addrs[1], Status: "fail", Message: "reserved range"},
}, 0
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"5.9.1.1", "203.0.113.7"})
require.Equal(t, int32(1), atomic.LoadInt32(calls))
require.Equal(t, 1, res.Requests)
require.Equal(t, 0, res.Failures)
g, ok := res.Geo["5.9.1.1"]
require.True(t, ok)
assert.Equal(t, "德国", g.Country)
assert.Equal(t, "DE", g.CountryCode)
assert.Equal(t, "Berlin", g.City)
assert.Equal(t, "AS24940", g.ASN, "AS 字段必须截成纯 ASN,否则国家统计里全是长串")
assert.True(t, g.HasCoords)
assert.Equal(t, ProviderIPAPI, g.Source)
// status != success 才是「服务明确说没有」,才可以被缓存成查不到
assert.True(t, res.Unresolved["203.0.113.7"])
assert.NotContains(t, res.Geo, "203.0.113.7")
}
// TestLookupNetworkFailure 三态里的「没查成」:绝不能进 Unresolved。
//
// 这条断言是本包最重要的回归保护:一旦失败也进 Unresolved,上层就会把它
// 写进缓存(3 天),此后每份报告的定位覆盖率都会莫名其妙变成 0。
func TestLookupNetworkFailure(t *testing.T) {
srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) {
return nil, http.StatusBadGateway
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"8.8.4.4", "1.0.0.1"})
assert.Equal(t, int32(ipAPIAttempts), atomic.LoadInt32(calls), "必须重试,但只重试一次")
assert.Equal(t, 1, res.Requests)
assert.Equal(t, 1, res.Failures)
assert.Empty(t, res.Geo)
assert.Empty(t, res.Unresolved, "网络失败不能记成「这个 IP 没有位置」")
}
// TestLookupRetrySucceeds 第一次抖动、第二次正常:不该丢数据。
func TestLookupRetrySucceeds(t *testing.T) {
var attempt int32
srv, _ := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) {
if atomic.AddInt32(&attempt, 1) == 1 {
return nil, http.StatusGatewayTimeout
}
return []ipAPIResult{okIP(addrs[0], "日本", "JP", "Tokyo", 35.68, 139.69, "", "")}, 0
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"133.242.0.1"})
require.Equal(t, 0, res.Failures)
assert.Equal(t, "日本", res.Geo["133.242.0.1"].Country)
}
// TestLookupBatches 超过 100 个地址必须分批,而不是一次塞进去被拒。
func TestLookupBatches(t *testing.T) {
srv, calls := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) {
assert.LessOrEqual(t, len(addrs), ipAPIBatchSize, "单批不能超过 ip-api 的硬上限")
out := make([]ipAPIResult, 0, len(addrs))
for _, a := range addrs {
out = append(out, okIP(a, "美国", "US", "Ashburn", 39.0, -77.5, "", ""))
}
return out, 0
})
addrs := make([]string, 0, 150)
for i := 0; i < 150; i++ {
addrs = append(addrs, fmt.Sprintf("198.51.%d.%d", i/256, i%256))
}
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), addrs)
assert.Equal(t, int32(2), atomic.LoadInt32(calls))
assert.Equal(t, 2, res.Requests)
assert.Len(t, res.Geo, 150)
}
// TestLookupSkipsPrivate 私网 / 回环 / CGNAT 一律不外发:既不泄露内网结构,
// 也省下配额(这类地址查了也是空的)。
func TestLookupSkipsPrivate(t *testing.T) {
srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) {
return nil, http.StatusInternalServerError
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{
"10.0.0.1", "192.168.1.1", "172.16.0.9", "127.0.0.1",
"100.64.3.4", "169.254.1.1", "::1", "fe80::1", "fd00::1",
})
assert.Equal(t, int32(0), atomic.LoadInt32(calls), "不可查询的地址不该产生任何请求")
assert.Equal(t, 0, res.Requests)
assert.Empty(t, res.Geo)
assert.Empty(t, res.Unresolved)
}
// TestLookupNone provider = none:一个字节都不外发。
func TestLookupNone(t *testing.T) {
srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) {
return nil, http.StatusInternalServerError
})
r := New(Options{Provider: ProviderNone, HTTPClient: newTestClient(t, srv)})
assert.False(t, r.Enabled())
res := r.Lookup(context.Background(), []string{"1.2.3.4"})
assert.Equal(t, int32(0), atomic.LoadInt32(calls))
assert.Empty(t, res.Geo)
}
// TestLookupEmptyKeys 空输入不该发请求。
func TestLookupEmptyKeys(t *testing.T) {
srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) {
return nil, http.StatusInternalServerError
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
assert.Empty(t, r.Lookup(context.Background(), nil).Geo)
assert.Equal(t, int32(0), atomic.LoadInt32(calls))
}
// TestLookupIPv6Prefix IPv6 的 "/64" 缓存键要还原成基址去查。
func TestLookupIPv6Prefix(t *testing.T) {
srv, _ := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) {
require.Len(t, addrs, 1)
assert.Equal(t, "2001:4860:4860::", addrs[0], "带 /64 后缀的键必须剥掉后缀再查")
return []ipAPIResult{okIP(addrs[0], "美国", "US", "Mountain View", 37.4, -122.0, "Google", "AS15169 Google LLC")}, 0
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"2001:4860:4860::/64"})
g, ok := res.Geo["2001:4860:4860::/64"]
require.True(t, ok, "结果键必须仍是原来的缓存键,不能变成查询用的地址")
assert.Equal(t, "US", g.CountryCode)
assert.Equal(t, "AS15169", g.ASN)
}
// tencentServer 起一个假的腾讯 IP 定位接口。
func tencentServer(t *testing.T, respond func(ip string) (tencentResult, int)) (*httptest.Server, *int32) {
t.Helper()
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/ws/location/v1/ip" {
http.NotFound(w, req)
return
}
atomic.AddInt32(&calls, 1)
tr, code := respond(req.URL.Query().Get("ip"))
if code != 0 {
http.Error(w, "upstream failure", code)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tr)
}))
t.Cleanup(srv.Close)
return srv, &calls
}
// combinedServer 同时提供两条通道的测试服务器(auto 模式要两条都通)。
func combinedServer(t *testing.T, ipapi func([]string) ([]ipAPIResult, int), tencent func(ip string) (tencentResult, int)) (*httptest.Server, *int32, *int32) {
t.Helper()
var ipCalls, txCalls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/batch":
atomic.AddInt32(&ipCalls, 1)
var addrs []string
if err := json.NewDecoder(req.Body).Decode(&addrs); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
results, code := ipapi(addrs)
if code != 0 {
http.Error(w, "upstream failure", code)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(results)
case "/ws/location/v1/ip":
atomic.AddInt32(&txCalls, 1)
tr, code := tencent(req.URL.Query().Get("ip"))
if code != 0 {
http.Error(w, "upstream failure", code)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tr)
default:
http.NotFound(w, req)
}
}))
t.Cleanup(srv.Close)
return srv, &ipCalls, &txCalls
}
func tencentOK(nation, province, city string, lat, lon float64) tencentResult {
var tr tencentResult
tr.Status = 0
tr.Result.Location.Lat, tr.Result.Location.Lng = lat, lon
tr.Result.AdInfo.Nation = nation
tr.Result.AdInfo.Province = province
tr.Result.AdInfo.City = city
return tr
}
// TestLookupAutoOnlyRefinesChina auto 模式只对国内 IP 调腾讯:
// 海外 IP 腾讯给不出省市,查了也是白花配额。
func TestLookupAutoOnlyRefinesChina(t *testing.T) {
srv, _, txCalls := combinedServer(t,
func(addrs []string) ([]ipAPIResult, int) {
return []ipAPIResult{
okIP(addrs[0], "中国", "CN", "", 0, 0, "", ""),
okIP(addrs[1], "美国", "US", "Ashburn", 39.0, -77.5, "", ""),
}, 0
},
func(ip string) (tencentResult, int) {
return tencentOK("中国", "北京市", "北京市", 39.9087, 116.3975), 0
})
r := New(Options{Provider: ProviderAuto, TencentKey: "k", HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"114.114.114.114", "45.32.0.1"})
assert.Equal(t, int32(1), atomic.LoadInt32(txCalls), "只该精修 1 个国内 IP")
cn := res.Geo["114.114.114.114"]
assert.Equal(t, "北京市", cn.Region)
assert.Equal(t, "北京市", cn.City)
assert.Equal(t, "CN", cn.CountryCode)
assert.InDelta(t, 39.9087, cn.Lat, 1e-9)
assert.Equal(t, ProviderTencent, cn.Source)
us := res.Geo["45.32.0.1"]
assert.Equal(t, "US", us.CountryCode)
assert.Equal(t, "Ashburn", us.City, "海外 IP 应保留 ip-api 的结果")
assert.Equal(t, ProviderIPAPI, us.Source)
}
// TestLookupTencentRefineCap 精修数量必须有上限,否则周报会跑十几分钟。
//
// 顺带守住一个容易踩的坑:精修必须按调用方给的顺序取前 N 个。报告是按命中
// 次数从高到低传进来的,若这里按 map 遍历顺序取,每次运行精修到的 IP 都不一样,
// 配额会花在长尾地址上,「主要从哪个城市来」这个问题就答不准了。
func TestLookupTencentRefineCap(t *testing.T) {
var mu sync.Mutex
var refined []string
srv, _, _ := combinedServer(t,
func(addrs []string) ([]ipAPIResult, int) {
out := make([]ipAPIResult, 0, len(addrs))
for _, a := range addrs {
out = append(out, okIP(a, "中国", "CN", "", 0, 0, "", ""))
}
return out, 0
},
func(ip string) (tencentResult, int) {
mu.Lock()
refined = append(refined, ip)
mu.Unlock()
return tencentOK("中国", "广东省", "深圳市", 22.5, 114.0), 0
})
addrs := []string{"1.1.1.1", "2.2.2.2", "3.3.3.3", "4.4.4.4", "5.5.5.5"}
r := New(Options{
Provider: ProviderAuto, TencentKey: "k", TencentRefineCap: 2,
HTTPClient: newTestClient(t, srv),
})
res := r.Lookup(context.Background(), addrs)
require.Len(t, refined, 2)
assert.ElementsMatch(t, []string{"1.1.1.1", "2.2.2.2"}, refined,
"必须精修前 2 个(最活跃的),而不是随机的 2 个")
// 精修到的用腾讯结果
assert.Equal(t, ProviderTencent, res.Geo["1.1.1.1"].Source)
// 没精修到的仍保留 ip-api 的国家
assert.Equal(t, "CN", res.Geo["5.5.5.5"].CountryCode)
assert.Equal(t, ProviderIPAPI, res.Geo["5.5.5.5"].Source)
}
// TestLookupTencentFailureNotCached 腾讯配额用尽 / key 无效属于「没查成」,
// 不能把整批 IP 记成「查不到」。ip-api 已经给出的结果也不能被抹掉。
func TestLookupTencentFailureNotCached(t *testing.T) {
srv, _, _ := combinedServer(t,
func(addrs []string) ([]ipAPIResult, int) {
return []ipAPIResult{okIP(addrs[0], "中国", "CN", "", 0, 0, "", "")}, 0
},
func(string) (tencentResult, int) {
var tr tencentResult
tr.Status = 311
tr.Message = "key格式错误"
return tr, 0
})
r := New(Options{Provider: ProviderAuto, TencentKey: "bad", HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"114.114.114.114"})
assert.Equal(t, 1, res.Failures)
assert.Empty(t, res.Unresolved, "配额/key 问题不是「这个 IP 没数据」")
assert.Equal(t, "CN", res.Geo["114.114.114.114"].CountryCode, "ip-api 的结果不能被腾讯的失败抹掉")
}
// TestLookupTencentNoData 状态成功但没有任何行政区划 = 真的没数据。
// 但若 ip-api 已经定位到了,也不能反过来把这条记成「查不到」。
func TestLookupTencentNoData(t *testing.T) {
srv, _, _ := combinedServer(t,
func(addrs []string) ([]ipAPIResult, int) {
return []ipAPIResult{okIP(addrs[0], "中国", "CN", "", 0, 0, "", "")}, 0
},
func(string) (tencentResult, int) { return tencentOK("中国", "", "", 0, 0), 0 })
r := New(Options{Provider: ProviderAuto, TencentKey: "k", HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"114.114.114.114"})
assert.Equal(t, 0, res.Failures)
assert.NotContains(t, res.Unresolved, "114.114.114.114", "已定位的 IP 不该被降级成查不到")
assert.Equal(t, "CN", res.Geo["114.114.114.114"].CountryCode)
}
// TestTencentOnly 只用腾讯(provider = tencent):不经 ip-api,全部走腾讯。
func TestTencentOnly(t *testing.T) {
srv, _, txCalls := combinedServer(t,
func([]string) ([]ipAPIResult, int) {
t.Error("provider = tencent 时不该请求 ip-api")
return nil, 0
},
func(string) (tencentResult, int) {
return tencentOK("中国", "浙江省", "杭州市", 30.27, 120.15), 0
})
r := New(Options{Provider: ProviderTencent, TencentKey: "k", HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"115.236.1.1"})
assert.Equal(t, int32(1), atomic.LoadInt32(txCalls))
assert.Equal(t, "杭州市", res.Geo["115.236.1.1"].City)
assert.Equal(t, "CN", res.Geo["115.236.1.1"].CountryCode)
}
// TestNewFallbacks 配置错误不能表现为「静默不工作」。
func TestNewFallbacks(t *testing.T) {
// 只选 tencent 却没 key → 降级 ipapi
r := New(Options{Provider: ProviderTencent})
assert.Equal(t, ProviderIPAPI, r.provider)
assert.True(t, r.Enabled())
// 空值 → auto
assert.Equal(t, ProviderAuto, New(Options{}).provider)
// 不认识的值 → auto
assert.Equal(t, ProviderAuto, New(Options{Provider: "maxmind"}).provider)
// none 不工作
assert.False(t, New(Options{Provider: ProviderNone}).Enabled())
// refineCap 缺省
assert.Equal(t, defaultTencentRefineCap, New(Options{}).refineCap)
}
// TestDescribe 描述会写进报告,读者靠它判断坐标可信度。
func TestDescribe(t *testing.T) {
assert.Contains(t, New(Options{Provider: ProviderAuto, TencentKey: "k"}).Describe(), "腾讯")
assert.Contains(t, New(Options{Provider: ProviderAuto}).Describe(), "未配置腾讯 key")
assert.Contains(t, New(Options{Provider: ProviderIPAPI}).Describe(), "ip-api")
assert.Contains(t, New(Options{Provider: ProviderNone}).Describe(), "未启用")
}
// TestQueryAddr 缓存键还原。
func TestQueryAddr(t *testing.T) {
cases := []struct {
key string
want string
ok bool
}{
{"8.8.8.8", "8.8.8.8", true},
{"2001:4860:4860::/64", "2001:4860:4860::", true},
{"2001:4860:4860::8888", "2001:4860:4860::8888", true},
{"10.1.2.3", "", false},
{"::ffff:8.8.8.8", "8.8.8.8", true},
{"not-an-ip", "", false},
{"", "", false},
}
for _, c := range cases {
got, ok := queryAddr(c.key)
assert.Equal(t, c.ok, ok, "key = %q", c.key)
assert.Equal(t, c.want, got, "key = %q", c.key)
}
}
// TestNormalizeASN ASN 只留编号。
func TestNormalizeASN(t *testing.T) {
assert.Equal(t, "AS15169", normalizeASN("AS15169 Google LLC"))
assert.Equal(t, "AS4134", normalizeASN(" AS4134 "))
assert.Equal(t, "AS4134", normalizeASN("AS4134"))
assert.Equal(t, "", normalizeASN(""))
}
// TestTencentCountryCode 不能把海外 IP 算成中国来源。
func TestTencentCountryCode(t *testing.T) {
assert.Equal(t, "CN", tencentCountryCode("中国"))
assert.Equal(t, "", tencentCountryCode("中国香港"))
assert.Equal(t, "", tencentCountryCode("美国"))
assert.Equal(t, "", tencentCountryCode(""))
}