This commit is contained in:
2026-07-30 18:01:49 +08:00
parent d063f6cd34
commit a701522ee0
7 changed files with 234 additions and 55 deletions
+12 -9
View File
@@ -14,16 +14,19 @@ type SearchPoiReq struct {
}
type PoiItem struct {
PoiId string `json:"poiId" dc:"POI ID"`
Name string `json:"name" dc:"商户名称"`
Address string `json:"address" dc:"地址"`
Phone string `json:"phone" dc:"联系电话"`
Category string `json:"category" dc:"分类"`
City string `json:"city" dc:"城市"`
Longitude string `json:"longitude" dc:"经度"`
Latitude string `json:"latitude" dc:"纬度"`
PoiId string `json:"poiId" dc:"POI ID"`
Name string `json:"name" dc:"商户名称"`
Address string `json:"address" dc:"地址"`
Phone string `json:"phone" dc:"联系电话"`
Category string `json:"category" dc:"分类"`
CategoryCode string `json:"categoryCode" dc:"分类编码"`
Province string `json:"province" dc:"省份"`
City string `json:"city" dc:"城市"`
District string `json:"district" dc:"区县"`
Longitude string `json:"longitude" dc:"经度"`
Latitude string `json:"latitude" dc:"纬度"`
BusinessArea string `json:"businessArea" dc:"商圈"`
Website string `json:"website" dc:"网址"`
Website string `json:"website" dc:"网址"`
}
type SuggestPoiReq struct {
+1 -1
View File
@@ -49,7 +49,7 @@ func (s *categoryService) GetDistricts(ctx context.Context, req *dto.DistrictReq
func (s *categoryService) fetchDistricts(ctx context.Context, keywords, apiKey string) ([]*dto.DistrictItem, error) {
requestUrl := "https://restapi.amap.com/v3/config/district?keywords=" + keywords + "&subdistrict=1&key=" + apiKey + "&extensions=base"
response, err := g.Client().Get(ctx, requestUrl)
response, err := amapClient.Get(ctx, requestUrl)
if err != nil {
return nil, gerror.New("高德API请求失败")
}
+175 -23
View File
@@ -20,8 +20,10 @@ type poiService struct{}
var PoiService = new(poiService)
var amapClient = g.Client().Timeout(30 * time.Second)
const (
maxOffset = 20 // 每页展示条数
maxOffset = 25 // 每页展示条数(高德最大25
maxPage = 100 // 高德API最大页数
)
@@ -55,6 +57,9 @@ type amapPoi struct {
Tel amapString `json:"tel"`
Location string `json:"location"`
BusinessArea amapString `json:"business_area"`
Pname amapString `json:"pname"`
Cityname amapString `json:"cityname"`
Adname amapString `json:"adname"`
City amapString `json:"city"`
CityCode amapString `json:"citycode"`
Website amapString `json:"website"`
@@ -85,7 +90,17 @@ func (s *poiService) Search(ctx context.Context, req *dto.SearchPoiReq) (res *dt
}
if types != "" {
req.Types = types
return s.searchByTypes(ctx, apiKey, req, page, offset)
// 获取区县列表,按区县 × 分类循环搜索
var places []string
if req.City != "" {
places, err = s.fetchCityDistricts(ctx, req.City, apiKey)
if err != nil {
g.Log().Warning(ctx, "获取区县列表失败,使用城市级搜索", "error", err)
}
}
return s.searchByTypes(ctx, apiKey, req, places, page, offset)
}
if req.Longitude != 0 && req.Latitude != 0 {
@@ -175,8 +190,8 @@ func (s *poiService) searchAround(ctx context.Context, apiKey string, req *dto.S
return s.buildResult(all, page, offset), nil
}
// searchByTypes 按行业分类搜索(拆分子分类独立调用,合并去重
func (s *poiService) searchByTypes(ctx context.Context, apiKey string, req *dto.SearchPoiReq, page, offset int) (*dto.SearchPoiRes, error) {
// searchByTypes 按行业分类搜索(有区县列表时按区县 × 分类循环,每组合查1页;否则逐分类全页搜索
func (s *poiService) searchByTypes(ctx context.Context, apiKey string, req *dto.SearchPoiReq, places []string, page, offset int) (*dto.SearchPoiRes, error) {
const baseUrl = "https://restapi.amap.com/v3/place/text"
// 解析分类编码,展开为中类(叶子节点)
@@ -189,9 +204,76 @@ func (s *poiService) searchByTypes(ctx context.Context, apiKey string, req *dto.
all := make([]*dto.PoiItem, 0, needTotal)
seen := map[string]bool{}
// 有区县列表时:按区县 × 分类循环,每组合获取全部分页,全局限定最大请求数
if len(places) > 0 {
maxAPICalls := 150
callCount := 0
rateLimitDelay := 200 * time.Millisecond
for _, place := range places {
if callCount >= maxAPICalls {
break
}
for _, tc := range typeCodes {
if callCount >= maxAPICalls {
break
}
if callCount > 0 {
time.Sleep(rateLimitDelay)
}
// 第1页:获取count以计算总页数
pois, totalCount := s.fetchAmapPage(ctx, apiKey, baseUrl, req.Keywords, tc, place, 1, offset)
callCount++
if len(pois) == 0 {
continue
}
// 计算总页数
totalPages := (totalCount + offset - 1) / offset
if totalPages > maxPage {
totalPages = maxPage
}
// 处理第1页数据
for _, p := range pois {
if seen[p.Id] {
continue
}
if string(p.Tel) == "" {
continue
}
seen[p.Id] = true
all = append(all, s.toPoiItem(p))
}
// 第2页起继续获取
for ap := 2; ap <= totalPages && callCount < maxAPICalls; ap++ {
time.Sleep(rateLimitDelay)
morePois, _ := s.fetchAmapPage(ctx, apiKey, baseUrl, req.Keywords, tc, place, ap, offset)
callCount++
if len(morePois) == 0 {
break
}
for _, p := range morePois {
if seen[p.Id] {
continue
}
if string(p.Tel) == "" {
continue
}
seen[p.Id] = true
all = append(all, s.toPoiItem(p))
}
}
}
}
return s.buildResult(all, page, offset), nil
}
// 无区县列表时:按分类逐页搜索(原逻辑)
for _, tc := range typeCodes {
var noPhone []*amapPoi
// 遍历该子分类所有页面
for ap := 1; ap <= maxPage; ap++ {
if ap > 1 || len(noPhone) > 0 {
time.Sleep(50 * time.Millisecond)
@@ -215,7 +297,6 @@ func (s *poiService) searchByTypes(ctx context.Context, apiKey string, req *dto.
break
}
}
// 兜底补全电话
if len(noPhone) > 0 {
extra := s.fallbackPhone(ctx, apiKey, noPhone, req.City, "")
for _, item := range extra {
@@ -264,7 +345,10 @@ func (s *poiService) toPoiItem(p *amapPoi) *dto.PoiItem {
Address: string(p.Address),
Phone: string(p.Tel),
Category: p.Type,
City: string(p.City),
CategoryCode: p.Typecode,
Province: string(p.Pname),
City: string(p.Cityname),
District: string(p.Adname),
Longitude: gconv.String(lng),
Latitude: gconv.String(lat),
BusinessArea: string(p.BusinessArea),
@@ -370,7 +454,7 @@ func (s *poiService) Suggest(ctx context.Context, req *dto.SuggestPoiReq) (*dto.
requestUrl := "https://restapi.amap.com/v3/assistant/inputtips?" + params.Encode()
response, err := g.Client().Get(ctx, requestUrl)
response, err := amapClient.Get(ctx, requestUrl)
if err != nil {
return nil, gerror.New("高德API请求失败")
}
@@ -432,7 +516,7 @@ func (s *poiService) fetchAroundPage(ctx context.Context, apiKey, baseUrl, keywo
requestUrl := baseUrl + "?" + params.Encode()
g.Log().Info(ctx, "高德POI周边搜索请求", "keywords", keywords, "location", location, "radius", radius, "page", page, "offset", offset)
response, err := g.Client().Get(ctx, requestUrl)
response, err := amapClient.Get(ctx, requestUrl)
if err != nil {
g.Log().Warning(ctx, "高德API请求失败", "error", err)
return nil, 0, err
@@ -455,7 +539,7 @@ func (s *poiService) fetchAroundPage(ctx context.Context, apiKey, baseUrl, keywo
return amapResp.Pois, gconv.Int(amapResp.Count), nil
}
// fetchAmapPage 调用高德文本搜索单页接口
// fetchAmapPage 调用高德文本搜索单页接口(遇限流自动重试)
func (s *poiService) fetchAmapPage(ctx context.Context, apiKey, baseUrl, keywords, types, city string, page, offset int) ([]*amapPoi, int) {
params := url.Values{}
params.Set("key", apiKey)
@@ -475,27 +559,95 @@ func (s *poiService) fetchAmapPage(ctx context.Context, apiKey, baseUrl, keyword
requestUrl := baseUrl + "?" + params.Encode()
g.Log().Info(ctx, "高德POI请求", "keywords", keywords, "types", types, "city", city, "page", page, "offset", offset)
response, err := g.Client().Get(ctx, requestUrl)
for retry := 0; retry < 3; retry++ {
if retry > 0 {
time.Sleep(1 * time.Second)
}
response, err := amapClient.Get(ctx, requestUrl)
if err != nil {
g.Log().Warning(ctx, "高德API请求失败", "error", err)
continue
}
bodyBytes := response.ReadAll()
response.Close()
var amapResp amapResponse
if err := json.Unmarshal(bodyBytes, &amapResp); err != nil {
g.Log().Warning(ctx, "高德API响应解析失败", "error", err)
return nil, 0
}
if amapResp.Status == "1" {
return amapResp.Pois, gconv.Int(amapResp.Count)
}
// 限流/其他错误 — 重试
g.Log().Warning(ctx, "高德API返回错误", "info", amapResp.Info, "retry", retry)
if !strings.Contains(amapResp.Info, "CUQPS") && !strings.Contains(amapResp.Info, "QPS") {
return nil, 0
}
}
return nil, 0
}
// fetchCityDistricts 获取城市下的所有区县名称列表(含直辖市的虚拟区划处理)
func (s *poiService) fetchCityDistricts(ctx context.Context, city, apiKey string) ([]string, error) {
requestUrl := "https://restapi.amap.com/v3/config/district?keywords=" + url.QueryEscape(city) + "&subdistrict=1&key=" + apiKey + "&extensions=base"
response, err := amapClient.Get(ctx, requestUrl)
if err != nil {
g.Log().Warning(ctx, "高德API请求失败", "error", err)
return nil, 0
return nil, gerror.New("高德API请求失败")
}
defer response.Close()
bodyBytes := response.ReadAll()
var amapResp amapResponse
if err := json.Unmarshal(bodyBytes, &amapResp); err != nil {
g.Log().Warning(ctx, "高德API响应解析失败", "error", err)
return nil, 0
var amapResp struct {
Status string `json:"status"`
Info string `json:"info"`
Districts []struct {
Name string `json:"name"`
Districts []struct {
Name string `json:"name"`
} `json:"districts"`
} `json:"districts"`
}
if err := json.Unmarshal(response.ReadAll(), &amapResp); err != nil {
return nil, gerror.New("高德API响应解析失败")
}
if amapResp.Status != "1" || len(amapResp.Districts) == 0 {
return nil, gerror.New("未找到该城市信息")
}
if amapResp.Status != "1" {
g.Log().Warning(ctx, "高德API返回错误", "info", amapResp.Info)
return nil, 0
cityData := amapResp.Districts[0]
// 检测所有子级区划是否均为虚拟分组(如"北京城区"、"重庆郊县"
allVirtual := len(cityData.Districts) > 0
for _, d := range cityData.Districts {
if !strings.HasSuffix(d.Name, "城区") && !strings.HasSuffix(d.Name, "郊县") {
allVirtual = false
break
}
}
// 虚拟分组,递归获取真实区县
if allVirtual {
var names []string
for _, vd := range cityData.Districts {
subNames, err := s.fetchCityDistricts(ctx, vd.Name, apiKey)
if err != nil {
continue
}
names = append(names, subNames...)
}
return names, nil
}
return amapResp.Pois, gconv.Int(amapResp.Count)
var names []string
for _, d := range cityData.Districts {
names = append(names, d.Name)
}
return names, nil
}
func parseLocation(location string) (float64, float64) {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pointly-Tel - 高德POI商户查询</title>
<script type="module" crossorigin src="/assets/index-BggdCNJQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ihli0-Cw.css">
<script type="module" crossorigin src="/assets/index-C_dWvpn_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bm3pUfxc.css">
</head>
<body>
<div id="app"></div>
+28 -4
View File
@@ -69,8 +69,8 @@
<!-- 结果列表 -->
<template v-else>
<div class="panel-header">
<span class="panel-title"> {{ total }} 结果</span>
<span class="panel-page"> {{ page }}/{{ maxPage }} </span>
<span class="panel-title">搜索结果</span>
<span class="panel-page"> {{ page }} </span>
</div>
<div class="panel-list">
@@ -82,6 +82,10 @@
@click="focusItem(item)"
>
<div class="card-name">{{ item.name }}</div>
<div class="card-meta">
<span class="card-tag">{{ item.category }}</span>
</div>
<div class="card-area">{{ [item.province, item.city, item.district].filter(Boolean).join(' ') }}</div>
<div class="card-phone">{{ item.phone || '暂无电话' }}</div>
<div class="card-addr">{{ item.address }}</div>
</div>
@@ -89,7 +93,7 @@
<div class="panel-footer">
<button class="page-btn" :disabled="page <= 1" @click="page--; doSearch()">上一页</button>
<button class="page-btn" :disabled="page >= maxPage" @click="page++; doSearch()">下一页</button>
<button class="page-btn" :disabled="!hasNextPage" @click="page++; doSearch()">下一页</button>
</div>
</template>
</div>
@@ -119,6 +123,7 @@ const searched = ref(false)
const error = ref('')
const page = ref(1)
const maxPage = ref(1)
const hasNextPage = computed(() => results.value.length >= 20)
const activePoi = ref(null)
const suggestions = ref([])
const suggestIndex = ref(-1)
@@ -739,7 +744,26 @@ html, body, #app {
.card-name {
font-size: 14px;
font-weight: 600;
margin-bottom: 3px;
margin-bottom: 2px;
}
.card-meta {
margin-bottom: 2px;
}
.card-tag {
display: inline-block;
font-size: 11px;
color: #1a73e8;
background: #e8f0fe;
padding: 1px 6px;
border-radius: 3px;
}
.card-area {
font-size: 12px;
color: #666;
margin-bottom: 2px;
}
.card-phone {