2026-08-03 05:52:02 +00:00
|
|
|
|
package domain
|
|
|
|
|
|
|
2026-08-19 02:01:15 +00:00
|
|
|
|
import (
|
|
|
|
|
|
"sort"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
)
|
2026-08-03 05:52:02 +00:00
|
|
|
|
|
|
|
|
|
|
// regionAliases maps common Taiwan place names (zh) → service area code.
|
|
|
|
|
|
// Exact alias match only — never geo-infer (OP-04).
|
|
|
|
|
|
var regionAliases = map[string]string{
|
|
|
|
|
|
"台北": "TPE", "臺北": "TPE", "台北市": "TPE", "臺北市": "TPE", "tpe": "TPE",
|
|
|
|
|
|
"新北": "NWT", "新北市": "NWT", "nwt": "NWT",
|
|
|
|
|
|
"桃園": "TAO", "桃園市": "TAO", "tao": "TAO",
|
|
|
|
|
|
"台中": "TXG", "臺中": "TXG", "台中市": "TXG", "臺中市": "TXG", "txg": "TXG",
|
|
|
|
|
|
"台南": "TNN", "臺南": "TNN", "台南市": "TNN", "臺南市": "TNN", "tnn": "TNN",
|
|
|
|
|
|
"高雄": "KHH", "高雄市": "KHH", "khh": "KHH",
|
|
|
|
|
|
"基隆": "KEE", "基隆市": "KEE", "kee": "KEE",
|
|
|
|
|
|
"新竹市": "HSZ", "hsz": "HSZ",
|
|
|
|
|
|
"新竹縣": "HSQ", "hsq": "HSQ", "新竹": "HSZ",
|
|
|
|
|
|
"苗栗": "MIA", "苗栗縣": "MIA", "mia": "MIA",
|
|
|
|
|
|
"彰化": "CHA", "彰化縣": "CHA", "cha": "CHA",
|
|
|
|
|
|
"南投": "NAN", "南投縣": "NAN", "nan": "NAN",
|
|
|
|
|
|
"雲林": "YUN", "雲林縣": "YUN", "yun": "YUN",
|
|
|
|
|
|
"嘉義市": "CYI", "cyi": "CYI",
|
|
|
|
|
|
"嘉義縣": "CYQ", "cyq": "CYQ", "嘉義": "CYI",
|
|
|
|
|
|
"屏東": "PIF", "屏東縣": "PIF", "pif": "PIF",
|
|
|
|
|
|
"宜蘭": "ILA", "宜蘭縣": "ILA", "ila": "ILA",
|
|
|
|
|
|
"花蓮": "HUA", "花蓮縣": "HUA", "hua": "HUA",
|
|
|
|
|
|
"台東": "TTT", "臺東": "TTT", "台東縣": "TTT", "臺東縣": "TTT", "ttt": "TTT",
|
|
|
|
|
|
"澎湖": "PEN", "澎湖縣": "PEN", "pen": "PEN",
|
|
|
|
|
|
"金門": "KIN", "金門縣": "KIN", "kin": "KIN",
|
|
|
|
|
|
"連江": "LIE", "連江縣": "LIE", "馬祖": "LIE", "lie": "LIE",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 02:01:15 +00:00
|
|
|
|
type regionAliasEntry struct {
|
|
|
|
|
|
runes []rune
|
|
|
|
|
|
code string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// sortedRegionAliases is built once at startup: longest alias first so 「台北市」
|
|
|
|
|
|
// wins over 「台北」, with the alias itself breaking ties so detection order is
|
|
|
|
|
|
// reproducible instead of following map iteration.
|
|
|
|
|
|
var sortedRegionAliases = buildSortedRegionAliases()
|
|
|
|
|
|
|
|
|
|
|
|
func buildSortedRegionAliases() []regionAliasEntry {
|
|
|
|
|
|
out := make([]regionAliasEntry, 0, len(regionAliases))
|
|
|
|
|
|
for alias, code := range regionAliases {
|
|
|
|
|
|
if alias == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
out = append(out, regionAliasEntry{runes: []rune(strings.ToLower(alias)), code: code})
|
|
|
|
|
|
}
|
|
|
|
|
|
sort.Slice(out, func(i, j int) bool {
|
|
|
|
|
|
if len(out[i].runes) != len(out[j].runes) {
|
|
|
|
|
|
return len(out[i].runes) > len(out[j].runes)
|
|
|
|
|
|
}
|
|
|
|
|
|
return string(out[i].runes) < string(out[j].runes)
|
|
|
|
|
|
})
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 05:52:02 +00:00
|
|
|
|
// DetectRegionCodes finds service-area codes mentioned in free text (exact alias only).
|
|
|
|
|
|
func DetectRegionCodes(text string) []string {
|
|
|
|
|
|
lower := strings.ToLower(text)
|
2026-08-19 02:01:15 +00:00
|
|
|
|
runes := []rune(lower)
|
|
|
|
|
|
if len(runes) == 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-08-03 05:52:02 +00:00
|
|
|
|
seen := map[string]bool{}
|
|
|
|
|
|
var out []string
|
2026-08-19 02:01:15 +00:00
|
|
|
|
covered := make([]bool, len(runes))
|
|
|
|
|
|
for _, p := range sortedRegionAliases {
|
|
|
|
|
|
ar := p.runes
|
|
|
|
|
|
if len(ar) > len(runes) {
|
2026-08-03 05:52:02 +00:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
for i := 0; i+len(ar) <= len(runes); i++ {
|
|
|
|
|
|
ok := true
|
|
|
|
|
|
for k := 0; k < len(ar); k++ {
|
|
|
|
|
|
if runes[i+k] != ar[k] || covered[i+k] {
|
|
|
|
|
|
ok = false
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
for k := 0; k < len(ar); k++ {
|
|
|
|
|
|
covered[i+k] = true
|
|
|
|
|
|
}
|
|
|
|
|
|
if !seen[p.code] {
|
|
|
|
|
|
seen[p.code] = true
|
|
|
|
|
|
out = append(out, p.code)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-19 02:01:15 +00:00
|
|
|
|
// also direct code tokens;map 迭代無序,排序後再併入以維持輸出穩定
|
|
|
|
|
|
direct := make([]string, 0)
|
2026-08-03 05:52:02 +00:00
|
|
|
|
for code := range serviceAreaCodes {
|
|
|
|
|
|
if strings.Contains(lower, strings.ToLower(code)) && !seen[code] {
|
|
|
|
|
|
seen[code] = true
|
2026-08-19 02:01:15 +00:00
|
|
|
|
direct = append(direct, code)
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-19 02:01:15 +00:00
|
|
|
|
sort.Strings(direct)
|
|
|
|
|
|
return append(out, direct...)
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MatchRegion compares detected codes against the owner's service areas.
|
|
|
|
|
|
// unknown when nothing detected; mismatch when only non-overlapping codes; match on any overlap.
|
|
|
|
|
|
func MatchRegion(detected, serviceAreas []string, remoteOK bool) (regionMatch string, score int) {
|
|
|
|
|
|
if len(detected) == 0 {
|
|
|
|
|
|
// partial credit for unknown (spec §3.3)
|
|
|
|
|
|
return RegionUnknown, WeightRegion / 2
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(serviceAreas) == 0 {
|
|
|
|
|
|
return RegionUnknown, WeightRegion / 2
|
|
|
|
|
|
}
|
|
|
|
|
|
svc := map[string]bool{}
|
|
|
|
|
|
for _, a := range serviceAreas {
|
|
|
|
|
|
svc[strings.ToUpper(strings.TrimSpace(a))] = true
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, d := range detected {
|
|
|
|
|
|
if svc[strings.ToUpper(strings.TrimSpace(d))] {
|
|
|
|
|
|
return RegionMatch, WeightRegion
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if remoteOK {
|
|
|
|
|
|
// OP-05: remote_ok → mismatch not hard-reject; full region points
|
|
|
|
|
|
return RegionMismatch, WeightRegion
|
|
|
|
|
|
}
|
|
|
|
|
|
return RegionMismatch, 0
|
|
|
|
|
|
}
|