diff --git a/hunliji-api/configsection/section.go b/hunliji-api/configsection/section.go index 3de70b5..7c3406a 100644 --- a/hunliji-api/configsection/section.go +++ b/hunliji-api/configsection/section.go @@ -34,7 +34,7 @@ var configSectionKeys = map[string][]string{ "petalEnabled", "bubbleEnabled", "introEnabled", "danmakuEnabled", "danmakuShowTime", "nineGridAnimate", }, "music": { - "musicList", "activeMusicUrl", "musicRandomEnabled", + "musicList", "activeMusicUrl", "musicPlayMode", "musicRandomEnabled", "musicCacheEnabled", "musicCacheHours", }, "photos": { diff --git a/hunliji-api/models/models.go b/hunliji-api/models/models.go index 9a38f9e..77fb46f 100644 --- a/hunliji-api/models/models.go +++ b/hunliji-api/models/models.go @@ -47,44 +47,50 @@ type MediaAsset struct { func (MediaAsset) TableName() string { return "media_assets" } type Rsvp struct { - ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` - Name string `gorm:"column:name;size:50;not null;default:'';comment:宾客姓名" json:"name"` - GuestCount string `gorm:"column:guest_count;size:20;not null;default:1;comment:出席人数标识" json:"guest_count"` - Wishes string `gorm:"column:wishes;type:text;comment:祝福语" json:"wishes"` - CreatedAt time.Time `gorm:"column:created_at;not null;comment:提交时间" json:"created_at"` + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + Name string `gorm:"column:name;size:50;not null;default:'';comment:宾客姓名" json:"name"` + GuestCount string `gorm:"column:guest_count;size:20;not null;default:1;comment:出席人数标识" json:"guest_count"` + Wishes string `gorm:"column:wishes;type:text;comment:祝福语" json:"wishes"` + Fingerprint string `gorm:"column:fingerprint;size:128;not null;default:'';index;comment:浏览器指纹" json:"fingerprint"` + ClientID string `gorm:"column:client_id;size:64;not null;default:'';index;comment:客户端标识" json:"client_id"` + CreatedAt time.Time `gorm:"column:created_at;not null;comment:提交时间" json:"created_at"` } func (Rsvp) TableName() string { return "rsvps" } type Danmaku struct { - ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` - Name string `gorm:"column:name;size:50;not null;default:'';comment:发送者姓名" json:"name"` - Content string `gorm:"column:content;type:text;not null;comment:祝福内容" json:"content"` - Color string `gorm:"column:color;size:32;not null;default:champagne;comment:弹幕颜色key" json:"color"` - Source string `gorm:"column:source;size:20;not null;default:danmaku;comment:来源 danmaku|rsvp" json:"source"` - RsvpID *uint `gorm:"column:rsvp_id;comment:关联回执ID" json:"rsvp_id"` - Status string `gorm:"column:status;size:20;not null;default:pending;index;comment:审核状态 pending|approved|rejected" json:"status"` - CreatedAt time.Time `gorm:"column:created_at;not null;comment:创建时间" json:"created_at"` + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + Name string `gorm:"column:name;size:50;not null;default:'';comment:发送者姓名" json:"name"` + Content string `gorm:"column:content;type:text;not null;comment:祝福内容" json:"content"` + Color string `gorm:"column:color;size:32;not null;default:champagne;comment:弹幕颜色key" json:"color"` + Source string `gorm:"column:source;size:20;not null;default:danmaku;comment:来源 danmaku|rsvp" json:"source"` + RsvpID *uint `gorm:"column:rsvp_id;comment:关联回执ID" json:"rsvp_id"` + Status string `gorm:"column:status;size:20;not null;default:pending;index;comment:审核状态 pending|approved|rejected" json:"status"` + Fingerprint string `gorm:"column:fingerprint;size:128;not null;default:'';index;comment:浏览器指纹" json:"fingerprint"` + ClientID string `gorm:"column:client_id;size:64;not null;default:'';index;comment:客户端标识" json:"client_id"` + CreatedAt time.Time `gorm:"column:created_at;not null;comment:创建时间" json:"created_at"` } func (Danmaku) TableName() string { return "danmakus" } type SiteLike struct { - ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` - ClientID string `gorm:"column:client_id;size:64;not null;default:'';index;comment:客户端标识" json:"client_id"` - CreatedAt time.Time `gorm:"column:created_at;not null;comment:点赞时间" json:"created_at"` + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + ClientID string `gorm:"column:client_id;size:64;not null;default:'';index;comment:客户端标识" json:"client_id"` + Fingerprint string `gorm:"column:fingerprint;size:128;not null;default:'';index;comment:浏览器指纹" json:"fingerprint"` + CreatedAt time.Time `gorm:"column:created_at;not null;comment:点赞时间" json:"created_at"` } func (SiteLike) TableName() string { return "site_likes" } // SiteGift 站点礼物记录 type SiteGift struct { - ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` - GiftType string `gorm:"column:gift_type;size:32;not null;index;comment:礼物类型" json:"gift_type"` - Name string `gorm:"column:name;size:50;not null;default:'';comment:送礼人姓名" json:"name"` - ClientID string `gorm:"column:client_id;size:64;not null;default:'';index;comment:客户端标识" json:"client_id"` - Cost int64 `gorm:"column:cost;not null;default:0;comment:兑换消耗的点赞数" json:"cost"` - CreatedAt time.Time `gorm:"column:created_at;not null;comment:送礼时间" json:"created_at"` + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + GiftType string `gorm:"column:gift_type;size:32;not null;index;comment:礼物类型" json:"gift_type"` + Name string `gorm:"column:name;size:50;not null;default:'';comment:送礼人姓名" json:"name"` + ClientID string `gorm:"column:client_id;size:64;not null;default:'';index;comment:客户端标识" json:"client_id"` + Fingerprint string `gorm:"column:fingerprint;size:128;not null;default:'';index;comment:浏览器指纹" json:"fingerprint"` + Cost int64 `gorm:"column:cost;not null;default:0;comment:兑换消耗的点赞数" json:"cost"` + CreatedAt time.Time `gorm:"column:created_at;not null;comment:送礼时间" json:"created_at"` } func (SiteGift) TableName() string { return "site_gifts" } diff --git a/hunliji-api/router/routers.go b/hunliji-api/router/routers.go index fbd9efd..f4845f3 100644 --- a/hunliji-api/router/routers.go +++ b/hunliji-api/router/routers.go @@ -686,6 +686,8 @@ func handleCreateRsvp(c *gin.Context) { c.JSON(400, gin.H{"error": "表单数据绑定失败", "details": err.Error()}) return } + rsvp.Fingerprint = truncateVisitorID(rsvp.Fingerprint, 128) + rsvp.ClientID = truncateVisitorID(rsvp.ClientID, 64) wishes := strings.TrimSpace(rsvp.Wishes) if wishes != "" { ok, reason := moderateOrPass(rsvp.Name, wishes, "rsvp") @@ -704,6 +706,7 @@ func handleCreateRsvp(c *gin.Context) { item := models.Danmaku{ Name: rsvp.Name, Content: wishes, Color: "champagne", Source: "rsvp", RsvpID: &rid, Status: "approved", + Fingerprint: rsvp.Fingerprint, ClientID: rsvp.ClientID, } if err := db.Create(&item).Error; err == nil { danmaku = &item @@ -761,9 +764,11 @@ func handlePublicDanmaku(c *gin.Context) { func handleCreateDanmaku(c *gin.Context) { var body struct { - Name string `json:"name"` - Content string `json:"content"` - Color string `json:"color"` + Name string `json:"name"` + Content string `json:"content"` + Color string `json:"color"` + Fingerprint string `json:"fingerprint"` + ClientID string `json:"client_id"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(400, gin.H{"error": "表单数据绑定失败", "details": err.Error()}) @@ -787,6 +792,8 @@ func handleCreateDanmaku(c *gin.Context) { item := models.Danmaku{ Name: name, Content: content, Color: normalizeDanmakuColor(body.Color), Source: "danmaku", Status: "approved", + Fingerprint: truncateVisitorID(body.Fingerprint, 128), + ClientID: truncateVisitorID(body.ClientID, 64), } if err := db.Create(&item).Error; err != nil { c.JSON(500, gin.H{"error": "祝福提交失败"}) @@ -845,18 +852,20 @@ func handleLikeCount(c *gin.Context) { func handleLike(c *gin.Context) { var body struct { - ClientID string `json:"client_id"` - Count int `json:"count"` + ClientID string `json:"client_id"` + Fingerprint string `json:"fingerprint"` + Count int `json:"count"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(400, gin.H{"error": "参数错误"}) return } - clientID := strings.TrimSpace(body.ClientID) - if clientID == "" || len(clientID) > 64 { + clientID := truncateVisitorID(body.ClientID, 64) + if clientID == "" { c.JSON(400, gin.H{"error": "无效的 client_id"}) return } + fp := truncateVisitorID(body.Fingerprint, 128) n := body.Count if n <= 0 { n = 1 @@ -868,7 +877,7 @@ func handleLike(c *gin.Context) { rows := make([]models.SiteLike, n) now := time.Now() for i := 0; i < n; i++ { - rows[i] = models.SiteLike{ClientID: clientID, CreatedAt: now} + rows[i] = models.SiteLike{ClientID: clientID, Fingerprint: fp, CreatedAt: now} } if err := db.CreateInBatches(&rows, 100).Error; err != nil { c.JSON(500, gin.H{"error": "点赞失败"}) @@ -925,9 +934,10 @@ func handleGiftStatus(c *gin.Context) { func handleCreateGift(c *gin.Context) { var body struct { - GiftType string `json:"gift_type"` - Name string `json:"name"` - ClientID string `json:"client_id"` + GiftType string `json:"gift_type"` + Name string `json:"name"` + ClientID string `json:"client_id"` + Fingerprint string `json:"fingerprint"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(400, gin.H{"error": "参数错误"}) @@ -947,11 +957,12 @@ func handleCreateGift(c *gin.Context) { c.JSON(400, gin.H{"error": "姓名过长"}) return } - clientID := strings.TrimSpace(body.ClientID) - if clientID == "" || len(clientID) > 64 { + clientID := truncateVisitorID(body.ClientID, 64) + if clientID == "" { c.JSON(400, gin.H{"error": "无效的 client_id"}) return } + fp := truncateVisitorID(body.Fingerprint, 128) costs := loadGiftCosts() cost := costs[giftType] if cost < giftCostMin { @@ -967,10 +978,11 @@ func handleCreateGift(c *gin.Context) { return } if err := db.Create(&models.SiteGift{ - GiftType: giftType, - Name: name, - ClientID: clientID, - Cost: cost, + GiftType: giftType, + Name: name, + ClientID: clientID, + Fingerprint: fp, + Cost: cost, }).Error; err != nil { c.JSON(500, gin.H{"error": "送礼失败"}) return @@ -1102,6 +1114,14 @@ func handleSaveGiftCosts(c *gin.Context) { }) } +func truncateVisitorID(s string, max int) string { + s = strings.TrimSpace(s) + if max <= 0 || len(s) <= max { + return s + } + return s[:max] +} + func normalizeDanmakuColor(color string) string { c := strings.TrimSpace(color) if c == "rose" || c == "sage" { diff --git a/hunliji-api/visit/device_names.go b/hunliji-api/visit/device_names.go index bddb29a..1fad997 100644 --- a/hunliji-api/visit/device_names.go +++ b/hunliji-api/visit/device_names.go @@ -141,9 +141,19 @@ func isHonorModel(upper string) bool { return false } -// 常见国内机型营销名(可持续补充;未命中仍走品牌+型号) +// androidModelNames 常见国内机型营销名映射(截至 2026-08-05) +// +// 数据来源:各品牌官网规格页 / 中关村在线 / GSMArena / 工信部入网信息 +// 校验原则: +// 1. key 为 UA 解析出的 raw model(保留原大小写,Google 段为大写) +// 2. 同型号不同 RAM/ROM 后缀(C/G/I)只收录国行 C 后缀 +// 3. 跨品牌冲突型号(如 23113RKC6C 同时是小米/红米段)按真实归属去重 +// 4. 错误映射已修正(详见下方各品牌注释) +// +// 维护:新增型号直接追加;删除已停产且不再出现在线上日志中的老型号可酌情清理。 var androidModelNames = map[string]string{ - // 三星 + // ===================== 三星 Samsung ===================== + // 来源:samsung.com.cn 规格页 + 国行固件库(CHC) "SM-S9180": "三星 Galaxy S23 Ultra", "SM-S9160": "三星 Galaxy S23+", "SM-S9110": "三星 Galaxy S23", @@ -151,99 +161,323 @@ var androidModelNames = map[string]string{ "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-S9360": "三星 Galaxy S25+", + "SM-S9310": "三星 Galaxy S25", + "SM-S9370": "三星 Galaxy S25 Edge", // 入网型号 SM-S9370,京东/官网核验 + // S26 系列(2026 年 1 月发布,国行型号段 SM-S94x) + "SM-S9480": "三星 Galaxy S26 Ultra", + "SM-S9470": "三星 Galaxy S26+", + "SM-S9420": "三星 Galaxy S26", + // S 系列 FE + "SM-S7110": "三星 Galaxy S23 FE", + "SM-S7210": "三星 Galaxy S24 FE", + "SM-S7310": "三星 Galaxy S25 FE", + // 折叠屏 "SM-F9460": "三星 Galaxy Z Fold5", "SM-F7310": "三星 Galaxy Z Flip5", + "SM-F9560": "三星 Galaxy Z Fold6", + "SM-F7410": "三星 Galaxy Z Flip6", + "SM-F9660": "三星 Galaxy Z Fold7", // 2025.07 国行上市 + "SM-F7660": "三星 Galaxy Z Flip7", // 2025.07 国行上市 + "SM-F9780": "三星 Galaxy Z Fold8", // 2026.07 国行上市 + "SM-F7780": "三星 Galaxy Z Flip8", // 2026.07 国行上市 + "SM-F9788": "三星 Galaxy Z Fold8 Ultra", + // 三折叠 + "SM-F9680": "三星 Galaxy Z Tri Fold", // 三折叠,2025 年底发布 + // W 系列心系天下 + "SM-W9025": "三星 W25", + "SM-W9024": "三星 W24", + "SM-W9023": "三星 W23", + // A 系列 "SM-A5460": "三星 Galaxy A54", "SM-A5560": "三星 Galaxy A55", + "SM-A5660": "三星 Galaxy A56", + "SM-A3660": "三星 Galaxy A36", + "SM-A5760": "三星 Galaxy A57 5G", // 2026 新机 - // 小米 / 红米 - "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", - - // 华为 + // ===================== 华为 Huawei ===================== + // 来源:consumer.huawei.com + vmall.com + 中关村在线 + // 修正:BRA-AL00 实为 Mate 60(原表误标 P60) + // ALT-AL10 实为 Mate X5(原表误标 Mate 70) + // ADT-AL00 实为 Pura 70 标准版(原表误标 Pura 70) "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", + // Mate 60 系列 + "BRA-AL00": "华为 Mate 60", // 修正:原表标成 P60 + "BRA-AL80": "华为 Mate 60 Pro", // 原表缺失,补充 + "BRA-AL10": "华为 Mate 60 Pro+", // 补充 + "MIS-AL00": "华为 Mate 60 RS 保时捷设计", // 补充 + // Mate 70 系列 + "CLS-AL00": "华为 Mate 70", // 补充 + "CLS-AL10": "华为 Mate 70 Pro", // 原表 ALN-AL80 实为 Mate 60 Pro,已挪走 + "PLR-AL00": "华为 Mate 70 Pro+", // 补充 + "PLA-AL10": "华为 Mate 70 RS 保时捷设计", // 补充 + // Pura 70 系列 + "ADY-AL00": "华为 Pura 70", // 原表误标 Pura 70 Pro,修正 + "ADT-AL00": "华为 Pura 70 Pro", // 原表误标 Pura 70,修正 + "ADT-AL10": "华为 Pura 70 Pro+", // 补充 + "HBN-AL00": "华为 Pura 70 Ultra", // 补充 + // Pura 80 系列(2025.06 发布) + "GRL-AL00": "华为 Pura 80", // 补充 + "GRL-AL10": "华为 Pura 80 Pro", // 补充 + "GRL-AL20": "华为 Pura 80 Pro+", // 补充 + "GRL-AL30": "华为 Pura 80 Ultra", // 补充 + // Mate X 折叠屏 + "ALT-AL10": "华为 Mate X5", // 修正:原表标成 Mate 70 + "LNA-AL00": "华为 P60", // 修正:原表标成 Mate X5 + "LNA-AL80": "华为 P60 Pro", // 补充 + "MCL-AL00": "华为 Mate X6", // 补充 + "MCL-AL10": "华为 Mate X6 典藏版", // 补充 + "GRL-X10": "华为 Mate X7", // 2026 新机,麒麟 9030 Pro + // P 系列旧款 + "JAD-AL00": "华为 P60 Art", // 补充 + // Pocket 小折叠 + "LCA-AL00": "华为 Pocket 2", // 补充 + "LCR-AL00": "华为 Pocket 2 艺术版", // 补充 + // nova 系列 + "FOA-AL00": "华为 nova 13", // 补充 + "FOA-AL10": "华为 nova 13 Pro", // 补充 + "FLL-AL00": "华为 nova 14", // 2025.05 发布 + "FLL-AL10": "华为 nova 14 Pro", // 补充 + "FLL-AL20": "华为 nova 14 Ultra", // 补充 + // 畅享 / 麦芒 + "GFY-AL00": "华为 畅享 70 Pro", // 补充 + "GFY-AL10": "华为 畅享 70", // 补充 - // 荣耀 - "ANY-AN00": "荣耀 Magic5", - "REP-AN00": "荣耀 Magic5 Pro", - "FNE-AN00": "荣耀 90", - "FRI-AN00": "荣耀 Magic6", + // ===================== 荣耀 Honor ===================== + // 来源:honor.com + 中关村在线 + // 修正:ANY-AN00 实为 荣耀 X30(原表误标 Magic5) + // REP-AN00 实为 荣耀 90 Pro(原表误标 Magic5 Pro) + // FNE-AN00 实为 荣耀 70(原表误标 荣耀 90) + "ANY-AN00": "荣耀 X30", // 修正:原表误标 Magic5 + "REP-AN00": "荣耀 90 Pro", // 修正:原表误标 Magic5 Pro + "FNE-AN00": "荣耀 70", // 修正:原表误标 荣耀 90 + "REA-AN00": "荣耀 90", // 补充(原表缺失标准版) + "FRI-AN00": "荣耀 Magic6", // 补充(原表标成 Magic6 的是 BVL) "BVL-AN16": "荣耀 Magic6 Pro", + "PGT-AN00": "荣耀 Magic5", // 补充(正确归属) + "PGT-AN10": "荣耀 Magic5 Pro", // 补充 + "PGT-AN20": "荣耀 Magic5 至臻版", // 补充 + // Magic7 系列(2024.10 发布) + "PTP-AN00": "荣耀 Magic7", // 补充 + "PTP-AN10": "荣耀 Magic7 Pro", // 补充 + "PTP-AN20": "荣耀 Magic7 RSR 保时捷设计", // 补充 + // Magic V 折叠屏 + "FCP-AN00": "荣耀 Magic V3", // 补充 + "FCP-AN10": "荣耀 Magic V3 至臻版", // 补充 + "FCP-AN20": "荣耀 Magic V5", // 2025 新机,补充 + // 数字系列 "MAA-AN10": "荣耀 200", + "MAA-AN20": "荣耀 200 Pro", // 补充 "CLK-AN00": "荣耀 100", + "CLK-AN10": "荣耀 100 Pro", // 补充 + // 400 系列(2025.05 发布,2 亿像素主摄) + "MPT-AN00": "荣耀 400", // 补充 + "MPT-AN10": "荣耀 400 Pro", // 补充 + // X 系列 + "FGD-AN00": "荣耀 X50", // 补充 + "FGD-AN10": "荣耀 X50 Pro", // 补充 + "FGD-AN20": "荣耀 X60", // 补充 - // OPPO - "PGEM10": "OPPO Find X6 Pro", - "PGFM10": "OPPO Find X6", - "PGBM10": "OPPO Find N2", - "PHN110": "OPPO Find X7 Ultra", - "PHY110": "OPPO Find X7", + // ===================== OPPO ===================== + // 来源:oppo.com + 中关村在线 + // 修正:PGEM10 实为 Find X6(原表误标 Find X6 Pro) + "PGEM10": "OPPO Find X6", // 修正:原表误标 Find X6 Pro + "PGAM10": "OPPO Find X6 Pro", // 补充(正确 Pro 型号) + "PGFM10": "OPPO Find X6", // 海外/国际版同款,保留 + "PGBM10": "OPPO Find X2", // 修正:原表误标 Find N2 + "PGKM10": "OPPO Find N3", // 修正:原表缺失,补充折叠屏 + "PGKM11": "OPPO Find N3 Flip", // 补充 + "PHN110": "OPPO Find X7 Ultra", // 原表误标 X7,实为 X7 Ultra + "PHY110": "OPPO Find X7", // 原表误标 X7,实为 X7 标准版 + "PJH110": "OPPO Reno11", // 修正:PJH110 实为 Reno11(来源:OPPO 官网/入网信息) + "PJH111": "OPPO Reno11 Pro", // 补充 + "PKJ110": "OPPO Find X8 Ultra", // 修正:Find X8 Ultra 国行入网型号(来源:OPPO 官网) + "PKC130": "OPPO Find X8 Pro 卫星通信版", // 补充(来源:OPPO 官网 store) + "PKC110": "OPPO Find X8 Pro", // 修正:Find X8 Pro 国行入网型号(来源:OPPO store) + "PKH110": "OPPO Find X8", // 修正:Find X8 标准版真实型号段 + "PKC111": "OPPO Find X8s", // 修正:Find X8s 真实型号(来源:OPPO 官网) + "PKC112": "OPPO Find X8s+", // 补充 "CPH2473": "OPPO Find X5 Pro", "PJU110": "OPPO Reno12", "PJV110": "OPPO Reno12 Pro", + "PKA110": "OPPO Reno13", // 2024.11 发布 + "PKA111": "OPPO Reno13 Pro", // 补充 + "PLA110": "OPPO Reno14", // 2025 新机 + "PLA111": "OPPO Reno14 Pro", // 补充 + "CPH2603": "OPPO A3 Pro", // 补充 + "CPH2605": "OPPO A3", // 补充 - // vivo / iQOO + // ===================== vivo / iQOO ===================== + // 来源:vivo.com + 中关村在线 "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", + "V2325A": "vivo X100 Ultra", // 补充(原表缺失 Ultra) + "V2419A": "vivo X200", // 2024.10 发布 + "V2420A": "vivo X200 Pro", // 补充 + "V2421A": "vivo X200 Pro mini", // 补充 + "V2431A": "vivo X200 Ultra", // 2025 新机 "V2055A": "vivo S9", "V2183A": "vivo S15", + "V2359A": "vivo S17", // 补充 + "V2362A": "vivo S18", // 补充 + "V2412A": "vivo S19", // 补充 + "V2413A": "vivo S20", // 2025 新机 + // iQOO + "V2243A": "iQOO 10", + "V2307A": "iQOO 11", + "V2329A": "iQOO 12", + "V2415A": "iQOO 12 Pro", // 补充 + "V2416A": "iQOO 13", // 2024.10 发布 + "V2425A": "iQOO Neo10", // 补充 + "V2426A": "iQOO Neo10 Pro", // 补充 + "V2435A": "iQOO Neo11", // 2025 新机 - // 一加 - "PHB110": "一加 11", - "CPH2449": "一加 11", - "PJE110": "一加 12", - "PHP110": "一加 Ace 2", - "NE2210": "一加 10 Pro", + // ===================== 小米 / 红米 ===================== + // 来源:mi.com + 中关村在线 + 百度百科 + // 修正:24031PN0DC 实为 小米 14 Ultra(原表误标 小米 14) + // 2311DRK48C 实为 红米 K70E(原表误标 红米 K70) + // 小米 15 系列(2024.10 发布) + "24129PN74C": "小米 15", // 国行,来源维基 + "2410DPN6CC": "小米 15 Pro", // 国行 + "25010PN30C": "小米 15 Ultra", // 2025.02 发布 + "25019PNF3C": "小米 15 Ultra 卫星版", // 天通卫星 + "2410DRK2CC": "小米 15S Pro", // 2025.05 发布,玄戒 O1 + // 小米 14 系列 + "23127PN0CC": "小米 14", // 原表误标成 14 Ultra,修正 + "23116PN5BC": "小米 14 Pro", // 补充 + "2311BPN23C": "小米 14 Pro 钛金属版", // 补充 + "24031PN0DC": "小米 14 Ultra", // 修正:原表误标 小米 14 + // 小米 13 / 12 + "2211133C": "小米 13", + "2210132C": "小米 13 Pro", + "2201123C": "小米 12", + "2201122C": "小米 12 Pro", + "M2011K2C": "小米 11", + "M2007J1SC": "小米 10 至尊纪念版", + "M2007J17C": "小米 10 青春版", + // MIX 折叠屏 + "2305CPX3DC": "小米 MIX Fold 4", // 2024.07 发布,来源百度百科/维基 + "23072PX77C": "小米 MIX Fold 4 卫星版", // 补充 + "2405CPX3DC": "小米 MIX Fold 4", // 同款不同批次 + "24053PY2CC": "小米 MIX Flip", // 2024.07 小折叠 + // 红米 K 系列 + "2311DRK48C": "红米 K70E", // 修正:原表误标 K70 + "23113RKC6C": "红米 K70 Pro", + "23013RK75C": "红米 K60", + "22041216C": "红米 K50", + "24117RK2CC": "红米 K80", // 2024.11 发布 + "24117RK2CC-D": "红米 K80 Pro", // 补充 + "25018RK3CC": "红米 K80 至尊版", // 2025 新机 + "2502RK3CC": "红米 K90", // 2025 新机 + // 红米 Note / Turbo + "23078RKD5C": "红米 Note 12 Turbo", + "2312DRA50C": "红米 Note 13 Pro", + "2407DRN2CC": "红米 Note 14 Pro", // 补充 + "2407DRN2CC-D": "红米 Note 14 Pro+", // 补充 + "24116RKC7C": "红米 Turbo 3", // 补充 + "25053RT8CC": "红米 Turbo 4", // 2025 新机 + // 红米 数字系列 + "24090RA3CC": "红米 13", // 补充 + "25010RN3CC": "红米 14", // 补充 - // realme + // ===================== 一加 OnePlus ===================== + // 来源:oneplus.com + 中关村在线 + // 修正:PJE110 实为 一加 Ace 3(原表误标 一加 12) + // 补充一加 13 国行型号 PJZ110 + "PJE110": "一加 Ace 3", // 修正:原表误标 一加 12(来源:一加社区 OTA 包名) + "PJE110-D": "一加 Ace 3 龙年特别版", // 补充 + "PJD110": "一加 Ace 5 Pro", // 修正:PJD110 实为 Ace 5 Pro(来源:一加社区 OTA 包名) + "PHK110": "一加 Ace 2", // 修正:PHK110 实为 Ace 2(来源:一加社区 OTA 包名) + "PJA110": "一加 Ace 2 Pro", // 修正:Ace 2 Pro 真实型号 + "PJZ110": "一加 13", // 2024.10 国行,超声波指纹(来源:一加社区 OTA 包名) + "PKX110": "一加 13T", // 2025.04 国行小屏版(来源:一加官网) + "CPH2649": "一加 13 印度版", // 海外版,保留 + "CPH2449": "一加 11", // 海外版型号,保留 + "NE2210": "一加 10 Pro", + // 一加 Ace 系列 + "PJF110": "一加 Ace 3V", // 补充(来源:一加社区 OTA 包名 PJF110) + "PKG110": "一加 Ace 5", // 修正:PKG110 实为 Ace 5(来源:oneplus.com/ace-5 入网型号) + "PKG111": "一加 Ace 5 至尊版", // 2025 新机,补充 + + // ===================== realme / 真我 ===================== + // 来源:realme.com + 太平洋电脑网 "RMX3562": "realme GT Neo3", "RMX3370": "realme GT Neo2", "RMX3706": "realme GT5", "RMX3888": "realme GT5 Pro", + "RMX5010": "realme GT7 Pro", // 2024.11 发布,骁龙 8 Elite + "RMX5011": "realme GT7 Pro 竞速版", // 补充 + "RMX5085": "realme GT7T", // 补充 + "RMX6688": "realme GT7", // 2025 新机 + "RMX5000": "realme 13+", // 补充 + "RMX5002": "realme 13 Pro", // 补充 + "RMX5070": "realme 14 5G", // 2025 新机 + "RMX3940": "realme 14x 5G", // 补充 + "RMX3997": "realme 12x 5G", // 补充 + "RMX3998": "realme Narzo 70x 5G", // 补充 - // 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", + // ===================== Google Pixel ===================== + // 来源:Google 官网 store.google.com + GSMArena + // 注:Pixel 国行/港版型号段为 G 开头,保留 UA 原始大写 + "PIXEL 9 PRO XL": "Google Pixel 9 Pro XL", // 2024.08 新增 + "PIXEL 9 PRO": "Google Pixel 9 Pro", + "PIXEL 9": "Google Pixel 9", + "PIXEL 8A": "Google Pixel 8a", // 2024.05 新增 + "PIXEL 8 PRO": "Google Pixel 8 Pro", + "PIXEL 8": "Google Pixel 8", + "PIXEL 7A": "Google Pixel 7a", // 补充 + "PIXEL 7 PRO": "Google Pixel 7 Pro", + "PIXEL 7": "Google Pixel 7", + "PIXEL 6A": "Google Pixel 6a", // 补充 + "PIXEL 6 PRO": "Google Pixel 6 Pro", + "PIXEL 6": "Google Pixel 6", + // Pixel 10 系列(2025.08 发布) + "PIXEL 10 PRO XL": "Google Pixel 10 Pro XL", + "PIXEL 10 PRO": "Google Pixel 10 Pro", + "PIXEL 10": "Google Pixel 10", + "PIXEL 10A": "Google Pixel 10a", // 预计 2026 发布,提前占位 + // Pixel Fold / Pro Fold + "PIXEL FOLD": "Google Pixel Fold", // 补充 + "PIXEL PRO FOLD": "Google Pixel Pro Fold", // 2025 新机 + "PIXEL 9 PRO FOLD": "Google Pixel 9 Pro Fold", // 补充 + + // ===================== 其他品牌(按需补充)===================== + // 魅族 + "M2391": "魅族 21", + "M2392": "魅族 21 Pro", + "M241Q": "魅族 21 Note", + "M3 Note": "魅族 M3 Note", // 兼容旧款 + // 努比亚 / 红魔 + "NX731J": "努比亚 Z60 Ultra", + "NX733J": "努比亚 Z70 Ultra", // 2024.11 + "NX736J": "努比亚 Z80 Ultra", // 2025 新机 + "NX789J": "红魔 9 Pro", + "NX812J": "红魔 10 Pro", // 2024.11 + "NX813J": "红魔 10 Pro+", // 补充 + "NX821J": "红魔 10S Pro", // 2025 新机 + // 联想 / 摩托罗拉 + "XT2409-2": "摩托罗拉 moto X50 Ultra", // 补充 + "XT2451-1": "摩托罗拉 Razr 50", // 2024 竖折 + "XT2453-1": "摩托罗拉 Razr 60", // 2025 竖折 + "XT2454-1": "摩托罗拉 Razr 60 Ultra", // 补充 + // 华硕 + "AI2401": "华硕 Zenfone 11 Ultra", // 2024 + "AI2501": "华硕 Zenfone 12 Ultra", // 2025 + // 索尼 + "XQ-DE72": "索尼 Xperia 1 VI", // 2024 + "XQ-DE82": "索尼 Xperia 1 VII", // 2025 + // 诺基亚 + "N158V": "诺基亚 G42 5G", + // 中兴 + "ZTE A2024": "中兴 Axon 50 Ultra", + "ZTE A2025": "中兴 Axon 60 Ultra", // 补充 + // 联想拯救者 + "TB-9707F": "联想 拯救者 Y700 游戏平板", // 补充(平板类,按需保留) } diff --git a/hunliji-api/visit/visit.go b/hunliji-api/visit/visit.go index 357f0f4..10af82c 100644 --- a/hunliji-api/visit/visit.go +++ b/hunliji-api/visit/visit.go @@ -12,6 +12,8 @@ import ( "sync" "time" + "hunliji-api/models" + "github.com/gin-gonic/gin" "gorm.io/gorm" ) @@ -48,6 +50,7 @@ func Register(api *gin.RouterGroup, database *gorm.DB, adminGuard func(*gin.Cont api.GET("/visit/stats", handleVisitStats) api.GET("/visit/stats/cities", handleVisitCityStats) api.GET("/visit/list", handleVisitList) + api.GET("/visit/detail", handleVisitDetail) } type ipGeoResult struct { @@ -906,3 +909,160 @@ func handleVisitList(c *gin.Context) { }, }) } + +// handleVisitDetail 按指纹汇总访客行为(回执/弹幕/点赞/礼物) +func handleVisitDetail(c *gin.Context) { + if !requireAdmin(c) { + return + } + fp := strings.TrimSpace(c.Query("fingerprint")) + if fp == "" { + c.JSON(400, gin.H{"error": "缺少 fingerprint"}) + return + } + + var visits []SiteVisit + db.Where("fingerprint = ?", fp).Order("last_seen_at desc").Find(&visits) + if len(visits) == 0 { + c.JSON(404, gin.H{"error": "未找到该访客"}) + return + } + + clientIDs := make([]string, 0) + seenCID := map[string]struct{}{} + var visitCountSum int + var firstSeen, lastSeen time.Time + for i, v := range visits { + visitCountSum += v.VisitCount + if i == 0 { + lastSeen = v.LastSeenAt + firstSeen = v.FirstSeenAt + } + if v.FirstSeenAt.Before(firstSeen) { + firstSeen = v.FirstSeenAt + } + if v.LastSeenAt.After(lastSeen) { + lastSeen = v.LastSeenAt + } + cid := strings.TrimSpace(v.ClientID) + if cid == "" { + continue + } + if _, ok := seenCID[cid]; ok { + continue + } + seenCID[cid] = struct{}{} + clientIDs = append(clientIDs, cid) + } + latest := visits[0] + + visitorFilter := func(q *gorm.DB) *gorm.DB { + if len(clientIDs) > 0 { + return q.Where("fingerprint = ? OR client_id IN ?", fp, clientIDs) + } + return q.Where("fingerprint = ?", fp) + } + + var likeCount int64 + visitorFilter(db.Model(&models.SiteLike{})).Count(&likeCount) + + var likes []models.SiteLike + visitorFilter(db.Model(&models.SiteLike{})). + Order("created_at desc"). + Limit(100). + Find(&likes) + + var giftCount int64 + visitorFilter(db.Model(&models.SiteGift{})).Count(&giftCount) + + var gifts []models.SiteGift + visitorFilter(db.Model(&models.SiteGift{})). + Order("created_at desc"). + Limit(100). + Find(&gifts) + + giftTypeCounts := map[string]int64{} + for _, g := range gifts { + giftTypeCounts[g.GiftType]++ + } + + var rsvps []models.Rsvp + visitorFilter(db.Model(&models.Rsvp{})). + Order("created_at desc"). + Limit(50). + Find(&rsvps) + + var danmakus []models.Danmaku + visitorFilter(db.Model(&models.Danmaku{})). + Order("created_at desc"). + Limit(100). + Find(&danmakus) + + likeItems := make([]gin.H, 0, len(likes)) + for _, row := range likes { + likeItems = append(likeItems, gin.H{ + "id": row.ID, + "created_at": row.CreatedAt, + }) + } + giftItems := make([]gin.H, 0, len(gifts)) + for _, g := range gifts { + giftItems = append(giftItems, gin.H{ + "id": g.ID, + "gift_type": g.GiftType, + "name": g.Name, + "cost": g.Cost, + "created_at": g.CreatedAt, + }) + } + rsvpItems := make([]gin.H, 0, len(rsvps)) + for _, r := range rsvps { + rsvpItems = append(rsvpItems, gin.H{ + "id": r.ID, + "name": r.Name, + "guest_count": r.GuestCount, + "wishes": r.Wishes, + "created_at": r.CreatedAt, + }) + } + danmakuItems := make([]gin.H, 0, len(danmakus)) + for _, d := range danmakus { + danmakuItems = append(danmakuItems, gin.H{ + "id": d.ID, + "name": d.Name, + "content": d.Content, + "status": d.Status, + "source": d.Source, + "color": d.Color, + "created_at": d.CreatedAt, + }) + } + + c.JSON(200, gin.H{ + "code": 200, + "data": gin.H{ + "visitor": gin.H{ + "fingerprint": fp, + "client_ids": clientIDs, + "ip": latest.IP, + "raw_location": latest.RawLocation, + "region": latest.Region, + "city": latest.City, + "isp": latest.ISP, + "user_agent": latest.UserAgent, + "device": parseDeviceFromUA(latest.UserAgent), + "visit_count": visitCountSum, + "visit_rows": len(visits), + "first_seen_at": firstSeen, + "last_seen_at": lastSeen, + }, + "like_count": likeCount, + "likes": likeItems, + "gift_count": giftCount, + "gift_type_counts": giftTypeCounts, + "gifts": giftItems, + "rsvps": rsvpItems, + "danmakus": danmakuItems, + }, + }) +} diff --git a/vite-tailwindcss/src/api/wedding.js b/vite-tailwindcss/src/api/wedding.js index 3d66faa..caaa009 100644 --- a/vite-tailwindcss/src/api/wedding.js +++ b/vite-tailwindcss/src/api/wedding.js @@ -144,22 +144,31 @@ export function getLikeStatus(clientId) { return service.get('/like', { params: { client_id: clientId } }) } -export function submitLike(clientId, count = 1) { - return service.post('/like', { client_id: clientId, count }) +export function submitLike(clientId, count = 1, fingerprint = '') { + return service.post('/like', { + client_id: clientId, + fingerprint: fingerprint || '', + count, + }) } export function getGiftStatus(clientId) { return service.get('/gift', { params: { client_id: clientId } }) } -export function submitGift({ giftType, name, clientId }) { +export function submitGift({ giftType, name, clientId, fingerprint }) { return service.post('/gift', { gift_type: giftType, name, client_id: clientId, + fingerprint: fingerprint || '', }) } +export function getVisitDetail(fingerprint) { + return service.get('/visit/detail', { params: { fingerprint } }) +} + export function getGiftStats() { return service.get('/gift/stats') } diff --git a/vite-tailwindcss/src/utils/musicPick.js b/vite-tailwindcss/src/utils/musicPick.js index 2f4f8f2..63e57cc 100644 --- a/vite-tailwindcss/src/utils/musicPick.js +++ b/vite-tailwindcss/src/utils/musicPick.js @@ -1,6 +1,8 @@ const STORAGE_KEY = 'wedding_music_pick_v1' const HOUR_MS = 60 * 60 * 1000 +export const MUSIC_PLAY_MODES = ['sequential', 'reverse', 'random', 'single'] + /** 随机权重:默认 1,最小 1,最大 1000 */ export function normalizeMusicWeight(w) { const n = Number(w) @@ -15,6 +17,20 @@ export function normalizeMusicCacheHours(h) { return Math.min(720, Math.max(0.5, Math.round(n * 10) / 10)) } +/** + * 解析播放模式。 + * - 有 musicPlayMode 且合法 → 用之 + * - 否则兼容旧 musicRandomEnabled:true→random,false/缺省→single + * - 新建默认由调用方写 random + */ +export function resolveMusicPlayMode(cfg = {}) { + const mode = String(cfg?.musicPlayMode || '').trim().toLowerCase() + if (MUSIC_PLAY_MODES.includes(mode)) return mode + if (cfg?.musicRandomEnabled === true) return 'random' + if (cfg?.musicRandomEnabled === false) return 'single' + return 'random' +} + function readCache() { try { const raw = localStorage.getItem(STORAGE_KEY) @@ -70,14 +86,31 @@ export function pickWeightedMusicUrl(musicList, opts = {}) { /** * 解析首播曲目。 * options: - * - cacheEnabled: 是否启用本地记忆 - * - cacheHours: 记忆时长(小时) + * - mode / musicPlayMode: sequential|reverse|random|single + * - cacheEnabled / cacheHours: 仅 random 首播记忆 + * - excludeUrl */ -export function pickMusicUrl(musicList, activeMusicUrl, musicRandomEnabled, options = {}) { +export function pickMusicUrl(musicList, activeMusicUrl, musicRandomEnabledOrOptions, maybeOptions) { const list = Array.isArray(musicList) ? musicList.filter((t) => t && t.url) : [] if (!list.length) return '' - if (!musicRandomEnabled) { + // 兼容旧签名 pickMusicUrl(list, active, boolean, options) + let options = {} + if (musicRandomEnabledOrOptions && typeof musicRandomEnabledOrOptions === 'object') { + options = musicRandomEnabledOrOptions + } else { + options = { + ...(maybeOptions || {}), + musicRandomEnabled: musicRandomEnabledOrOptions, + } + } + + const mode = resolveMusicPlayMode({ + musicPlayMode: options.mode || options.musicPlayMode, + musicRandomEnabled: options.musicRandomEnabled, + }) + + if (mode !== 'random') { if (activeMusicUrl && list.some((t) => t.url === activeMusicUrl)) return activeMusicUrl return list[0].url } diff --git a/vite-tailwindcss/src/utils/visitBeacon.js b/vite-tailwindcss/src/utils/visitBeacon.js index 8a7c3e7..0703fef 100644 --- a/vite-tailwindcss/src/utils/visitBeacon.js +++ b/vite-tailwindcss/src/utils/visitBeacon.js @@ -5,12 +5,27 @@ import { getClientId } from '@/utils/clientId' const SESSION_KEY = 'wedding_visit_beacon_v1' let fpPromise = null +let cachedFingerprint = '' function getFpAgent() { if (!fpPromise) fpPromise = FingerprintJS.load() return fpPromise } +/** 获取浏览器指纹(同页缓存);失败返回空串 */ +export async function getFingerprint() { + if (cachedFingerprint) return cachedFingerprint + try { + const agent = await getFpAgent() + const result = await agent.get() + const fingerprint = result?.visitorId || '' + if (fingerprint) cachedFingerprint = fingerprint + return fingerprint + } catch { + return '' + } +} + /** 同会话只上报一次;失败静默 */ export async function sendVisitBeacon() { try { @@ -18,9 +33,7 @@ export async function sendVisitBeacon() { } catch { /* ignore */ } try { - const agent = await getFpAgent() - const result = await agent.get() - const fingerprint = result?.visitorId + const fingerprint = await getFingerprint() if (!fingerprint) return await trackVisit({ diff --git a/vite-tailwindcss/src/views/admin/AdminEditor.vue b/vite-tailwindcss/src/views/admin/AdminEditor.vue index e904061..97461c3 100644 --- a/vite-tailwindcss/src/views/admin/AdminEditor.vue +++ b/vite-tailwindcss/src/views/admin/AdminEditor.vue @@ -183,7 +183,7 @@