1
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
Vendored
+2
-2
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user