1. 迎宾海报
This commit is contained in:
249
hunliji-api/visit/device_names.go
Normal file
249
hunliji-api/visit/device_names.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package visit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// friendlyAndroidName 将 UA 中的型号码映射为可读名称(品牌 + 营销名/型号)
|
||||
func friendlyAndroidName(model string) string {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return "Android"
|
||||
}
|
||||
key := strings.ToUpper(model)
|
||||
key = strings.ReplaceAll(key, " ", "")
|
||||
|
||||
if name, ok := androidModelNames[key]; ok {
|
||||
return name
|
||||
}
|
||||
// 部分型号带前缀品牌词
|
||||
if name, ok := androidModelNames[strings.ToUpper(strings.TrimSpace(model))]; ok {
|
||||
return name
|
||||
}
|
||||
|
||||
brand, rest := detectAndroidBrand(model)
|
||||
if brand != "" {
|
||||
if rest == "" || strings.EqualFold(rest, brand) {
|
||||
return brand
|
||||
}
|
||||
// 已是「Xiaomi 14」这类可读名
|
||||
if looksHumanDeviceName(model) {
|
||||
if !strings.HasPrefix(model, brand) && !strings.Contains(model, brand) {
|
||||
return brand + " " + model
|
||||
}
|
||||
return model
|
||||
}
|
||||
return brand + " " + rest
|
||||
}
|
||||
if looksHumanDeviceName(model) {
|
||||
return model
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func looksHumanDeviceName(s string) bool {
|
||||
hasLetter := false
|
||||
hasSpaceOrCJK := false
|
||||
for _, r := range s {
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
return true
|
||||
}
|
||||
if r == ' ' {
|
||||
hasSpaceOrCJK = true
|
||||
}
|
||||
if unicode.IsLetter(r) {
|
||||
hasLetter = true
|
||||
}
|
||||
}
|
||||
// Pixel 7 / Nexus 5X 等
|
||||
return hasLetter && hasSpaceOrCJK
|
||||
}
|
||||
|
||||
func detectAndroidBrand(model string) (brand, rest string) {
|
||||
m := strings.TrimSpace(model)
|
||||
upper := strings.ToUpper(m)
|
||||
|
||||
// 显式品牌前缀
|
||||
prefixes := []struct {
|
||||
prefix string
|
||||
brand string
|
||||
}{
|
||||
{"HUAWEI ", "华为"},
|
||||
{"HONOR ", "荣耀"},
|
||||
{"XIAOMI ", "小米"},
|
||||
{"REDMI ", "红米"},
|
||||
{"OPPO ", "OPPO"},
|
||||
{"VIVO ", "vivo"},
|
||||
{"SAMSUNG ", "三星"},
|
||||
{"ONEPLUS ", "一加"},
|
||||
{"REALME ", "realme"},
|
||||
{"MEIZU ", "魅族"},
|
||||
{"GOOGLE ", "Google"},
|
||||
{"PIXEL ", "Google"},
|
||||
}
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(upper, strings.ToUpper(p.prefix)) {
|
||||
return p.brand, strings.TrimSpace(m[len(p.prefix):])
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(upper, "SM-"), strings.HasPrefix(upper, "GT-"), strings.HasPrefix(upper, "SCH-"):
|
||||
return "三星", m
|
||||
case strings.HasPrefix(upper, "RMX"):
|
||||
return "realme", m
|
||||
case strings.HasPrefix(upper, "CPH"), strings.HasPrefix(upper, "PGFM"), strings.HasPrefix(upper, "PGBM"),
|
||||
strings.HasPrefix(upper, "PGEM"), strings.HasPrefix(upper, "PHN"), strings.HasPrefix(upper, "PHW"),
|
||||
strings.HasPrefix(upper, "PJU"), strings.HasPrefix(upper, "PJD"):
|
||||
return "OPPO", m
|
||||
case strings.HasPrefix(upper, "V2"), strings.HasPrefix(upper, "V1"), strings.HasPrefix(upper, "V3"):
|
||||
// vivo / iQOO 国内型号多为 VxxxxA
|
||||
if len(upper) >= 5 && unicode.IsDigit(rune(upper[1])) {
|
||||
return "vivo", m
|
||||
}
|
||||
case strings.HasPrefix(upper, "NE2"), strings.HasPrefix(upper, "LE2"), strings.HasPrefix(upper, "KB2"),
|
||||
strings.HasPrefix(upper, "PHB"), strings.HasPrefix(upper, "PHP"), strings.HasPrefix(upper, "CPH24"),
|
||||
strings.HasPrefix(upper, "ONEPLUS"):
|
||||
return "一加", m
|
||||
case strings.HasPrefix(upper, "M20"), strings.HasPrefix(upper, "M21"), strings.HasPrefix(upper, "220"),
|
||||
strings.HasPrefix(upper, "221"), strings.HasPrefix(upper, "222"), strings.HasPrefix(upper, "230"),
|
||||
strings.HasPrefix(upper, "231"), strings.HasPrefix(upper, "240"), strings.HasPrefix(upper, "241"),
|
||||
strings.HasPrefix(upper, "MI "), strings.HasPrefix(upper, "MI-"):
|
||||
return "小米", m
|
||||
case strings.Contains(upper, "REDMI"):
|
||||
return "红米", m
|
||||
case strings.HasSuffix(upper, "-AN00"), strings.HasSuffix(upper, "-AN10"), strings.HasSuffix(upper, "-AL00"),
|
||||
strings.HasSuffix(upper, "-AL10"), strings.HasSuffix(upper, "-TL00"), strings.HasSuffix(upper, "-LND"):
|
||||
// 华为/荣耀鸿蒙型号:XXX-AN00
|
||||
if isHonorModel(upper) {
|
||||
return "荣耀", m
|
||||
}
|
||||
return "华为", m
|
||||
case strings.HasPrefix(upper, "PIXEL"):
|
||||
return "Google", m
|
||||
case strings.HasPrefix(upper, "NEXUS"):
|
||||
return "Google", m
|
||||
}
|
||||
return "", m
|
||||
}
|
||||
|
||||
func isHonorModel(upper string) bool {
|
||||
honor := []string{
|
||||
"ANY-", "REP-", "FNE-", "FRI-", "ANN-", "ALA-", "BRT-", "SDY-", "ELP-",
|
||||
"VER-", "MAA-", "REA-", "NTP-", "GIA-", "CLS-", "DIO-", "LGE-", "CRT-",
|
||||
}
|
||||
for _, p := range honor {
|
||||
if strings.HasPrefix(upper, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 常见国内机型营销名(可持续补充;未命中仍走品牌+型号)
|
||||
var androidModelNames = map[string]string{
|
||||
// 三星
|
||||
"SM-S9180": "三星 Galaxy S23 Ultra",
|
||||
"SM-S9160": "三星 Galaxy S23+",
|
||||
"SM-S9110": "三星 Galaxy S23",
|
||||
"SM-S9280": "三星 Galaxy S24 Ultra",
|
||||
"SM-S9260": "三星 Galaxy S24+",
|
||||
"SM-S9210": "三星 Galaxy S24",
|
||||
"SM-S9380": "三星 Galaxy S25 Ultra",
|
||||
"SM-S9080": "三星 Galaxy S22 Ultra",
|
||||
"SM-S9010": "三星 Galaxy S22",
|
||||
"SM-G9980": "三星 Galaxy S21 Ultra",
|
||||
"SM-G9910": "三星 Galaxy S21",
|
||||
"SM-F9460": "三星 Galaxy Z Fold5",
|
||||
"SM-F7310": "三星 Galaxy Z Flip5",
|
||||
"SM-A5460": "三星 Galaxy A54",
|
||||
"SM-A5560": "三星 Galaxy A55",
|
||||
|
||||
// 小米 / 红米
|
||||
"24031PN0DC": "小米 14",
|
||||
"23127PN0CC": "小米 14",
|
||||
"2211133C": "小米 13",
|
||||
"2210132C": "小米 13 Pro",
|
||||
"2201123C": "小米 12",
|
||||
"2201122C": "小米 12 Pro",
|
||||
"M2011K2C": "小米 11",
|
||||
"M2007J1SC": "小米 10 至尊纪念版",
|
||||
"M2007J17C": "小米 10 青春版",
|
||||
"2311DRK48C": "红米 K70",
|
||||
"23113RKC6C": "红米 K70 Pro",
|
||||
"23013RK75C": "红米 K60",
|
||||
"22041216C": "红米 K50",
|
||||
"24117RK2CC": "红米 K80",
|
||||
"23078RKD5C": "红米 Note 12 Turbo",
|
||||
"2312DRA50C": "红米 Note 13 Pro",
|
||||
|
||||
// 华为
|
||||
"NOH-AN00": "华为 Mate 40 Pro",
|
||||
"NOH-AL10": "华为 Mate 40 Pro",
|
||||
"OCE-AN10": "华为 Mate 40",
|
||||
"ANA-AN00": "华为 P40",
|
||||
"ELS-AN00": "华为 P40 Pro",
|
||||
"BRA-AL00": "华为 P60",
|
||||
"ALN-AL00": "华为 Mate 60",
|
||||
"ALN-AL80": "华为 Mate 60 Pro",
|
||||
"LNA-AL00": "华为 Mate X5",
|
||||
"ALT-AL10": "华为 Mate 70",
|
||||
"PLA-AL10": "华为 Mate 70 Pro",
|
||||
"ADT-AL00": "华为 Pura 70",
|
||||
"ADY-AL00": "华为 Pura 70 Pro",
|
||||
|
||||
// 荣耀
|
||||
"ANY-AN00": "荣耀 Magic5",
|
||||
"REP-AN00": "荣耀 Magic5 Pro",
|
||||
"FNE-AN00": "荣耀 90",
|
||||
"FRI-AN00": "荣耀 Magic6",
|
||||
"BVL-AN16": "荣耀 Magic6 Pro",
|
||||
"MAA-AN10": "荣耀 200",
|
||||
"CLK-AN00": "荣耀 100",
|
||||
|
||||
// OPPO
|
||||
"PGEM10": "OPPO Find X6 Pro",
|
||||
"PGFM10": "OPPO Find X6",
|
||||
"PGBM10": "OPPO Find N2",
|
||||
"PHN110": "OPPO Find X7 Ultra",
|
||||
"PHY110": "OPPO Find X7",
|
||||
"CPH2473": "OPPO Find X5 Pro",
|
||||
"PJU110": "OPPO Reno12",
|
||||
"PJV110": "OPPO Reno12 Pro",
|
||||
|
||||
// vivo / iQOO
|
||||
"V2254A": "vivo X90 Pro+",
|
||||
"V2241A": "vivo X90",
|
||||
"V2227A": "vivo X90 Pro",
|
||||
"V2309A": "vivo X100",
|
||||
"V2324A": "vivo X100 Pro",
|
||||
"V2307A": "iQOO 11",
|
||||
"V2243A": "iQOO 10",
|
||||
"V2329A": "iQOO 12",
|
||||
"V2055A": "vivo S9",
|
||||
"V2183A": "vivo S15",
|
||||
|
||||
// 一加
|
||||
"PHB110": "一加 11",
|
||||
"CPH2449": "一加 11",
|
||||
"PJE110": "一加 12",
|
||||
"PHP110": "一加 Ace 2",
|
||||
"NE2210": "一加 10 Pro",
|
||||
|
||||
// realme
|
||||
"RMX3562": "realme GT Neo3",
|
||||
"RMX3370": "realme GT Neo2",
|
||||
"RMX3706": "realme GT5",
|
||||
"RMX3888": "realme GT5 Pro",
|
||||
|
||||
// Google
|
||||
"PIXEL 9 PRO": "Google Pixel 9 Pro",
|
||||
"PIXEL 9": "Google Pixel 9",
|
||||
"PIXEL 8 PRO": "Google Pixel 8 Pro",
|
||||
"PIXEL 8": "Google Pixel 8",
|
||||
"PIXEL 7 PRO": "Google Pixel 7 Pro",
|
||||
"PIXEL 7": "Google Pixel 7",
|
||||
"PIXEL 6 PRO": "Google Pixel 6 Pro",
|
||||
"PIXEL 6": "Google Pixel 6",
|
||||
}
|
||||
@@ -46,6 +46,7 @@ func Register(api *gin.RouterGroup, database *gorm.DB, adminGuard func(*gin.Cont
|
||||
requireAdmin = adminGuard
|
||||
api.POST("/visit", handleTrackVisit)
|
||||
api.GET("/visit/stats", handleVisitStats)
|
||||
api.GET("/visit/stats/cities", handleVisitCityStats)
|
||||
api.GET("/visit/list", handleVisitList)
|
||||
}
|
||||
|
||||
@@ -196,7 +197,7 @@ func parseIPGeoJSON(raw []byte) ipGeoResult {
|
||||
city := pick("city", "City")
|
||||
isp := pick("isp", "ISP", "operator", "org")
|
||||
|
||||
// result 常为完整文案,如「中国 广东省 深圳市 电信」
|
||||
// result 常为完整文案,如「中国|浙江省|杭州市|联通」——运营商单独记 isp,不并入地区
|
||||
rawLoc := resultStr
|
||||
if rawLoc == "" {
|
||||
parts := []string{}
|
||||
@@ -214,10 +215,24 @@ func parseIPGeoJSON(raw []byte) ipGeoResult {
|
||||
rawLoc = "未知"
|
||||
}
|
||||
|
||||
// 若只有完整文案,尽量拆出省用于统计
|
||||
tokens := normalizeLocTokens(rawLoc)
|
||||
if isp == "" {
|
||||
isp = extractISPFromTokens(tokens)
|
||||
}
|
||||
// 归属地展示去掉运营商,避免与 isp 字段重复、也避免被当成地区
|
||||
rawLoc = joinLocationWithoutISP(tokens)
|
||||
if rawLoc == "" {
|
||||
rawLoc = "未知"
|
||||
}
|
||||
|
||||
// 省/地区:结构化字段优先,但若整串误带了运营商则清洗;否则从文案推断
|
||||
region = sanitizeRegion(region)
|
||||
if region == "" && rawLoc != "" && rawLoc != "未知" && rawLoc != "本地" {
|
||||
region = guessProvince(rawLoc)
|
||||
}
|
||||
if city == "" {
|
||||
city = guessCity(tokens, region)
|
||||
}
|
||||
|
||||
return ipGeoResult{
|
||||
Country: country,
|
||||
@@ -228,22 +243,182 @@ func parseIPGeoJSON(raw []byte) ipGeoResult {
|
||||
}
|
||||
}
|
||||
|
||||
func guessProvince(raw string) string {
|
||||
// 粗略从完整归属地文案中取省级片段,供地区分布统计
|
||||
tokens := strings.Fields(strings.ReplaceAll(raw, " ", " "))
|
||||
// 常见运营商名:不得写入 region / raw_location
|
||||
var ispNameTokens = []string{
|
||||
"联通", "电信", "移动", "铁通", "广电", "教育网",
|
||||
"长城宽带", "鹏博士", "方正宽带", "宽带通",
|
||||
"China Unicom", "China Telecom", "China Mobile",
|
||||
"CHINANET", "UNICOM", "CMNET", "CRTC",
|
||||
}
|
||||
|
||||
func normalizeLocTokens(raw string) []string {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
replacer := strings.NewReplacer(
|
||||
"|", " ", "|", " ", "/", " ", "/", " ",
|
||||
" ", " ", ",", " ", ",", " ",
|
||||
)
|
||||
return strings.Fields(replacer.Replace(s))
|
||||
}
|
||||
|
||||
func isISPToken(t string) bool {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(t)
|
||||
for _, name := range ispNameTokens {
|
||||
if t == name || strings.EqualFold(t, name) {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(lower, strings.ToLower(name)) {
|
||||
return true
|
||||
}
|
||||
// 「杭州联通」「浙江电信」等后缀
|
||||
if strings.HasSuffix(t, name) {
|
||||
rest := strings.TrimSuffix(t, name)
|
||||
if rest == "" || len([]rune(rest)) <= 4 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractISPFromTokens(tokens []string) string {
|
||||
for i := len(tokens) - 1; i >= 0; i-- {
|
||||
if isISPToken(tokens[i]) {
|
||||
return tokens[i]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func joinLocationWithoutISP(tokens []string) string {
|
||||
parts := make([]string, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
if strings.Contains(t, "省") || strings.Contains(t, "自治区") ||
|
||||
strings.HasSuffix(t, "市") && (strings.HasPrefix(t, "北京") || strings.HasPrefix(t, "上海") ||
|
||||
strings.HasPrefix(t, "天津") || strings.HasPrefix(t, "重庆")) {
|
||||
if isISPToken(t) {
|
||||
continue
|
||||
}
|
||||
if len(parts) > 0 && parts[len(parts)-1] == t {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, t)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func sanitizeRegion(region string) string {
|
||||
region = strings.TrimSpace(region)
|
||||
if region == "" || region == "未知" {
|
||||
return ""
|
||||
}
|
||||
// 误把整段「中国|浙江省|杭州市|联通」写入 region 时,只保留省级
|
||||
if strings.ContainsAny(region, "||/ ") || isISPToken(region) {
|
||||
return guessProvince(region)
|
||||
}
|
||||
return normalizeMunicipalityName(region)
|
||||
}
|
||||
|
||||
var municipalityRoots = []string{"北京", "上海", "天津", "重庆"}
|
||||
|
||||
func isMunicipalityName(name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, m := range municipalityRoots {
|
||||
if name == m || name == m+"市" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeMunicipalityName 统一「北京」「北京市」→「北京市」,避免分布被拆开
|
||||
func normalizeMunicipalityName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
for _, m := range municipalityRoots {
|
||||
if name == m || name == m+"市" {
|
||||
return m + "市"
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func guessProvince(raw string) string {
|
||||
// 从完整归属地文案中取省级片段,供地区分布统计(不含运营商)
|
||||
tokens := normalizeLocTokens(raw)
|
||||
for _, t := range tokens {
|
||||
if isISPToken(t) {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(t, "省") || strings.Contains(t, "自治区") {
|
||||
return t
|
||||
}
|
||||
// 直辖市(含「北京」无「市」后缀)
|
||||
if isMunicipalityName(t) {
|
||||
return normalizeMunicipalityName(t)
|
||||
}
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if t == "中国" || t == "国内" || isISPToken(t) {
|
||||
continue
|
||||
}
|
||||
return normalizeMunicipalityName(t)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func guessCity(tokens []string, province string) string {
|
||||
province = normalizeMunicipalityName(province)
|
||||
// 优先区/县(直辖市下沉一级)
|
||||
for _, t := range tokens {
|
||||
if isISPToken(t) || t == "中国" || t == "国内" {
|
||||
continue
|
||||
}
|
||||
if t == province || normalizeMunicipalityName(t) == province {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(t, "区") || strings.HasSuffix(t, "县") {
|
||||
return t
|
||||
}
|
||||
}
|
||||
if len(tokens) >= 2 {
|
||||
return tokens[1]
|
||||
for _, t := range tokens {
|
||||
if isISPToken(t) || t == "中国" || t == "国内" {
|
||||
continue
|
||||
}
|
||||
if t == province || normalizeMunicipalityName(t) == province {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(t, "市") || strings.HasSuffix(t, "州") || strings.HasSuffix(t, "盟") {
|
||||
return t
|
||||
}
|
||||
}
|
||||
// 直辖市常无独立「市」字段:用直辖市名本身,避免落入「未知」
|
||||
if isMunicipalityName(province) {
|
||||
return normalizeMunicipalityName(province)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// normalizeRegionName 清洗历史脏数据(整串含运营商),用于地区分布聚合
|
||||
func normalizeRegionName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "未知"
|
||||
}
|
||||
if cleaned := sanitizeRegion(name); cleaned != "" {
|
||||
return normalizeMunicipalityName(cleaned)
|
||||
}
|
||||
if isISPToken(name) {
|
||||
return "未知"
|
||||
}
|
||||
return normalizeMunicipalityName(name)
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
@@ -261,14 +436,20 @@ func parseDeviceFromUA(ua string) string {
|
||||
lower := strings.ToLower(ua)
|
||||
|
||||
if strings.Contains(ua, "iPhone") {
|
||||
if ver := extractIOSVersion(ua); ver != "" {
|
||||
return "iPhone · iOS " + ver
|
||||
}
|
||||
return "iPhone"
|
||||
}
|
||||
if strings.Contains(ua, "iPad") {
|
||||
if ver := extractIOSVersion(ua); ver != "" {
|
||||
return "iPad · iOS " + ver
|
||||
}
|
||||
return "iPad"
|
||||
}
|
||||
if strings.Contains(lower, "android") {
|
||||
if model := extractAndroidModel(ua); model != "" {
|
||||
return model
|
||||
return friendlyAndroidName(model)
|
||||
}
|
||||
return "Android"
|
||||
}
|
||||
@@ -287,6 +468,31 @@ func parseDeviceFromUA(ua string) string {
|
||||
return "其他"
|
||||
}
|
||||
|
||||
func extractIOSVersion(ua string) string {
|
||||
// CPU iPhone OS 17_2 like Mac OS X → 17.2
|
||||
for _, key := range []string{"iPhone OS ", "CPU OS "} {
|
||||
idx := strings.Index(ua, key)
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
rest := ua[idx+len(key):]
|
||||
end := 0
|
||||
for end < len(rest) {
|
||||
c := rest[end]
|
||||
if (c >= '0' && c <= '9') || c == '_' || c == '.' {
|
||||
end++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if end == 0 {
|
||||
continue
|
||||
}
|
||||
return strings.ReplaceAll(rest[:end], "_", ".")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractAndroidModel(ua string) string {
|
||||
// 常见形态:Linux; Android 13; SM-S9180 Build/... 或 Android 12; Pixel 7)
|
||||
idx := strings.Index(ua, "Android")
|
||||
@@ -300,17 +506,24 @@ func extractAndroidModel(ua string) string {
|
||||
}
|
||||
rest = strings.TrimSpace(rest[semi+1:])
|
||||
end := len(rest)
|
||||
for _, sep := range []string{" Build", ")", "; HMSCore", "; HarmonyOS"} {
|
||||
for _, sep := range []string{" Build", ")", "; HMSCore", "; HarmonyOS", "; wv"} {
|
||||
if p := strings.Index(rest, sep); p >= 0 && p < end {
|
||||
end = p
|
||||
}
|
||||
}
|
||||
model := strings.TrimSpace(rest[:end])
|
||||
model = strings.Trim(model, "; ")
|
||||
// 部分 UA:HarmonyOS; NOH-AN00 Build
|
||||
if model == "" || strings.EqualFold(model, "wv") || strings.HasPrefix(strings.ToLower(model), "linux") {
|
||||
return ""
|
||||
}
|
||||
// 过长型号截断
|
||||
// 「zh-cn; SM-S9180」或多余语言标签
|
||||
if p := strings.LastIndex(model, ";"); p >= 0 {
|
||||
tail := strings.TrimSpace(model[p+1:])
|
||||
if tail != "" {
|
||||
model = tail
|
||||
}
|
||||
}
|
||||
r := []rune(model)
|
||||
if len(r) > 40 {
|
||||
model = string(r[:40])
|
||||
@@ -473,13 +686,31 @@ func handleVisitStats(c *gin.Context) {
|
||||
Name string `json:"name"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
var regions []regionRow
|
||||
var rawRegions []regionRow
|
||||
db.Model(&SiteVisit{}).
|
||||
Select("CASE WHEN region = '' OR region IS NULL THEN '未知' ELSE region END as name, COUNT(DISTINCT fingerprint) as value").
|
||||
Group("name").
|
||||
Order("value desc").
|
||||
Limit(30).
|
||||
Scan(®ions)
|
||||
Scan(&rawRegions)
|
||||
|
||||
// 合并历史脏数据(如「中国|浙江省|杭州市|联通」被整段写入 region)
|
||||
merged := map[string]int64{}
|
||||
for _, r := range rawRegions {
|
||||
name := normalizeRegionName(r.Name)
|
||||
merged[name] += r.Value
|
||||
}
|
||||
regions := make([]regionRow, 0, len(merged))
|
||||
for name, value := range merged {
|
||||
regions = append(regions, regionRow{Name: name, Value: value})
|
||||
}
|
||||
sort.Slice(regions, func(i, j int) bool {
|
||||
if regions[i].Value == regions[j].Value {
|
||||
return regions[i].Name < regions[j].Name
|
||||
}
|
||||
return regions[i].Value > regions[j].Value
|
||||
})
|
||||
if len(regions) > 30 {
|
||||
regions = regions[:30]
|
||||
}
|
||||
if regions == nil {
|
||||
regions = []regionRow{}
|
||||
}
|
||||
@@ -498,6 +729,127 @@ func handleVisitStats(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
type namedCount struct {
|
||||
Name string `json:"name"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
|
||||
// handleVisitCityStats 某省下的市级分布(按指纹去重)
|
||||
func handleVisitCityStats(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
regionQ := strings.TrimSpace(c.Query("region"))
|
||||
if regionQ == "" {
|
||||
c.JSON(400, gin.H{"error": "缺少 region 参数"})
|
||||
return
|
||||
}
|
||||
target := normalizeRegionName(regionQ)
|
||||
|
||||
type locRow struct {
|
||||
Fingerprint string
|
||||
Region string
|
||||
City string
|
||||
RawLocation string
|
||||
}
|
||||
var rows []locRow
|
||||
q := db.Model(&SiteVisit{}).Select("fingerprint, region, city, raw_location")
|
||||
if target == "未知" {
|
||||
q = q.Where("region = '' OR region IS NULL OR region = ? OR region LIKE ?", "未知", "%未知%")
|
||||
} else if target == "本地" {
|
||||
q = q.Where("region = ? OR raw_location = ?", "本地", "本地")
|
||||
} else {
|
||||
like := "%" + target + "%"
|
||||
q = q.Where("region = ? OR region LIKE ? OR raw_location LIKE ?", target, like, like)
|
||||
}
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "查询失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// fingerprint → city(同指纹多条时取首次有效市)
|
||||
fpCity := map[string]string{}
|
||||
for _, row := range rows {
|
||||
if !rowBelongsToRegion(row.Region, row.RawLocation, target) {
|
||||
continue
|
||||
}
|
||||
city := resolveVisitCity(row.City, row.RawLocation, target)
|
||||
if prev, ok := fpCity[row.Fingerprint]; ok {
|
||||
if prev != "未知" || city == "未知" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
fpCity[row.Fingerprint] = city
|
||||
}
|
||||
|
||||
merged := map[string]int64{}
|
||||
for _, city := range fpCity {
|
||||
merged[city]++
|
||||
}
|
||||
cities := make([]namedCount, 0, len(merged))
|
||||
for name, value := range merged {
|
||||
cities = append(cities, namedCount{Name: name, Value: value})
|
||||
}
|
||||
sort.Slice(cities, func(i, j int) bool {
|
||||
if cities[i].Value == cities[j].Value {
|
||||
return cities[i].Name < cities[j].Name
|
||||
}
|
||||
return cities[i].Value > cities[j].Value
|
||||
})
|
||||
if len(cities) > 40 {
|
||||
cities = cities[:40]
|
||||
}
|
||||
if cities == nil {
|
||||
cities = []namedCount{}
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{
|
||||
"region": target,
|
||||
"cities": cities,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func rowBelongsToRegion(region, raw, target string) bool {
|
||||
if target == "未知" {
|
||||
n := normalizeRegionName(region)
|
||||
return n == "未知" || n == ""
|
||||
}
|
||||
if normalizeRegionName(region) == target {
|
||||
return true
|
||||
}
|
||||
if guessProvince(raw) == target {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(region, target) || strings.Contains(raw, target)
|
||||
}
|
||||
|
||||
func resolveVisitCity(city, raw, province string) string {
|
||||
province = normalizeMunicipalityName(strings.TrimSpace(province))
|
||||
city = strings.TrimSpace(city)
|
||||
if city != "" && !isISPToken(city) {
|
||||
// 历史脏数据:city 里也可能是整段归属地
|
||||
if strings.ContainsAny(city, "||/") {
|
||||
if c := guessCity(normalizeLocTokens(city), province); c != "" {
|
||||
return c
|
||||
}
|
||||
} else if normalizeMunicipalityName(city) != province {
|
||||
// 普通省:市字段有效;若 city 本身是区县也保留
|
||||
return city
|
||||
}
|
||||
// city 与直辖市同名时,继续从 raw 里找区县,找不到再用直辖市名
|
||||
}
|
||||
if c := guessCity(normalizeLocTokens(raw), province); c != "" {
|
||||
return c
|
||||
}
|
||||
if isMunicipalityName(province) {
|
||||
return normalizeMunicipalityName(province)
|
||||
}
|
||||
return "未知"
|
||||
}
|
||||
|
||||
func handleVisitList(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user