package web import ( "fmt" "net" "net/http" "net/url" "regexp" "strings" "sync" "time" "daili/internal/domain" "daili/internal/store" ) // portalDay 客户自助月历中的一天:日常任务按 done/total 计,通行证/深渊按整段色块展示。 type portalDay struct { Date string Day int InMonth bool Today bool Total int Done int // 通行证 / 深渊色块 BlockColor string BlockText string BlockTitle string BlockPercent int // Label 是读屏用的整格描述 Label string } // portalSub 客户自助页里的一个订阅;复用管理端 SubView 的状态/剩余文案。 type portalSub struct { SubView TodayLabel string TodayClass string } // portalAsk 同一尾号命中多个客户时要求补姓名。 type portalAsk struct { Tail string Count int Masked []string } // portalView 客户自助查询结果。 type portalView struct { Tail string Name string Game string Subs []portalSub Weeks [][]portalDay MonthTitle string Prev string Next string TodayTotal int TodayDone int TodayPending int TodayState string // done / pending / none SubCount int Expiring string Masked []string } var portalTailRe = regexp.MustCompile(`^\d{4,8}$`) const ( portalRateLimit = 40 portalRateWindow = time.Minute ) var portalHits = struct { sync.Mutex m map[string][]time.Time }{m: map[string][]time.Time{}} // allowPortal 对自助查询做简单的按 IP 限流,避免尾号枚举把库打穿。 func allowPortal(remote string) bool { host, _, err := net.SplitHostPort(remote) if err != nil || host == "" { host = remote } now := time.Now() portalHits.Lock() defer portalHits.Unlock() kept := portalHits.m[host][:0] for _, t := range portalHits.m[host] { if now.Sub(t) < portalRateWindow { kept = append(kept, t) } } if len(kept) >= portalRateLimit { portalHits.m[host] = kept return false } portalHits.m[host] = append(kept, now) return true } // handleCustomerPortal 客户自助查询页(公开,无入口链接)。 func (s *Server) handleCustomerPortal(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Robots-Tag", "noindex, nofollow") p := s.newPage(r, "排单查询", "") q := r.URL.Query() tail := strings.TrimSpace(q.Get("tail")) name := strings.TrimSpace(q.Get("name")) month := strings.TrimSpace(q.Get("cm")) p.Query = tail p.Name = name if tail == "" { s.render(w, r, "portal", p) return } if !allowPortal(r.RemoteAddr) { p.InlineError = "查询太频繁,请稍后再试" s.render(w, r, "portal", p) return } if !portalTailRe.MatchString(tail) { p.InlineError = "请输入 4–8 位数字手机尾号" p.InvalidField = "tail" s.render(w, r, "portal", p) return } list, err := s.db.CustomersByTailExact(tail) if err != nil { p.InlineError = "读取客户失败,请稍后再试" s.render(w, r, "portal", p) return } if len(list) == 0 { p.InlineError = "没有找到这个尾号的客户,请核对尾号" p.InvalidField = "tail" s.render(w, r, "portal", p) return } if len(list) > 1 { if name == "" { p.D = &portalAsk{Tail: tail, Count: len(list), Masked: maskNames(list)} s.render(w, r, "portal", p) return } matched := matchByName(list, name) if len(matched) == 0 { p.InlineError = "尾号对得上,但姓名不匹配,请再确认一下" p.InvalidField = "name" s.render(w, r, "portal", p) return } if len(matched) > 1 { p.D = &portalAsk{Tail: tail, Count: len(matched), Masked: maskNames(matched)} s.render(w, r, "portal", p) return } list = matched } c := list[0] p.D = s.buildPortal(c, tail, name, month) s.render(w, r, "portal", p) } func matchByName(list []store.Customer, name string) []store.Customer { needle := strings.ToLower(strings.TrimSpace(name)) out := make([]store.Customer, 0, len(list)) for _, c := range list { if strings.Contains(strings.ToLower(c.Name), needle) { out = append(out, c) } } return out } func maskNames(list []store.Customer) []string { out := make([]string, 0, len(list)) seen := map[string]bool{} for _, c := range list { m := maskName(c.Name) if seen[m] { continue } seen[m] = true out = append(out, m) } return out } // maskName 只保留首字,避免多客户态直接列出全名。 func maskName(name string) string { rs := []rune(strings.TrimSpace(name)) switch len(rs) { case 0: return "*" case 1: return "*" } return string(rs[0]) + strings.Repeat("*", len(rs)-1) } // buildPortal 组装某位客户的排单视图。 func (s *Server) buildPortal(c store.Customer, tail, name, month string) *portalView { today := domain.Today() todayStr := domain.Date(today) warn := s.warnDays() first := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC) if month != "" { if t, err := time.Parse("2006-01", month); err == nil { first = t } } year, mon := first.Year(), int(first.Month()) firstDay := time.Date(year, time.Month(mon), 1, 0, 0, 0, 0, time.UTC) lastDay := firstDay.AddDate(0, 1, -1) start := firstDay.AddDate(0, 0, -((int(firstDay.Weekday()) + 6) % 7)) end := lastDay.AddDate(0, 0, 6-((int(lastDay.Weekday())+6)%7)) allSubs, _ := s.db.SubsByCustomer(c.ID) subs := make([]store.Subscription, 0, len(allSubs)) ids := make([]int64, 0, len(allSubs)) for _, sub := range allSubs { if sub.Status == domain.StatusClosed { continue } subs = append(subs, sub) ids = append(ids, sub.ID) } cks, _ := s.db.CheckinsForSubs(ids, wideFrom(domain.Date(start)), wideTo(domain.Date(end))) pauses, _ := s.db.PausesForSubs(ids) cals := make([]calSub, 0, len(subs)) for _, sub := range subs { cs := calSub{ sub: sub, start: domain.ParseDateOr(sub.StartDate, today), eval: sub.Eval(today, warn), pauses: pauses[sub.ID], checkins: cks[sub.ID], } if sub.EndDate != "" { cs.hasEnd = true cs.end = domain.ParseDateOr(sub.EndDate, today) } cals = append(cals, cs) } v := &portalView{ Tail: c.Tail, Name: c.Name, Game: c.GameName, SubCount: len(subs), } v.MonthTitle = fmt.Sprintf("%d 年 %d 月", year, mon) v.Prev = portalQuery(tail, name, firstDay.AddDate(0, -1, 0).Format("2006-01")) v.Next = portalQuery(tail, name, firstDay.AddDate(0, 1, 0).Format("2006-01")) for _, cs := range cals { sub := cs.sub ev := newSubView(sub, today, warn) ps := portalSub{SubView: ev} if ev.StateClass == "active" && !sub.Season && !sub.Abyss && cs.covers(todayStr) { if ci, ok := cks[sub.ID][todayStr]; ok && portalDone(ci) { ps.TodayLabel, ps.TodayClass = "今日已完成", "active" } else { ps.TodayLabel, ps.TodayClass = "今日未完成", "plain" } } v.Subs = append(v.Subs, ps) if sub.EndDate != "" && sub.EndDate >= todayStr { switch ev.Eval.State { case domain.StateExpired, domain.StateUsedUp, domain.StateClosed: default: if v.Expiring == "" || sub.EndDate < v.Expiring { v.Expiring = sub.EndDate } } } } // 月历 days := int(end.Sub(start).Hours()/24) + 1 week := make([]portalDay, 0, 7) for i := 0; i < days; i++ { d := start.AddDate(0, 0, i) ds := domain.Date(d) pd := portalDay{Date: ds, Day: d.Day(), InMonth: d.Month() == time.Month(mon), Today: ds == todayStr} // 与主日历一致:只在本月格子里显示任务,前后月只做日期占位。 if pd.InMonth { for _, cs := range cals { sub := cs.sub if cs.isBlock() { if cs.blockCovers(ds) && pd.BlockText == "" { pd.BlockColor = sub.PackageColor pd.BlockText = blockCellText(cs, ds) pd.BlockTitle = sub.PackageName + " · " + pd.BlockText pd.BlockPercent = blockCellPercent(cs, d) } continue } if !cs.covers(ds) { continue } pd.Total++ if ci, ok := cks[sub.ID][ds]; ok && portalDone(ci) { pd.Done++ } } } pd.Label = portalDayLabel(pd, mon) week = append(week, pd) if len(week) == 7 { v.Weeks = append(v.Weeks, week) week = make([]portalDay, 0, 7) } } if len(week) > 0 { v.Weeks = append(v.Weeks, week) } // 今日进度:整段型订阅算 1 项,通行证看 season_done,深渊看当天所在周期。 for _, cs := range cals { if cs.isBlock() { if cs.blockCovers(todayStr) { v.TodayTotal++ if !cs.blockNeedsWork(todayStr) { v.TodayDone++ } } continue } if !cs.covers(todayStr) { continue } v.TodayTotal++ if ci, ok := cks[cs.sub.ID][todayStr]; ok && portalDone(ci) { v.TodayDone++ } } v.TodayPending = v.TodayTotal - v.TodayDone switch { case v.TodayTotal == 0: v.TodayState = "none" case v.TodayPending <= 0: v.TodayState = "done" default: v.TodayState = "pending" } return v } // portalDone 与管理端日历口径一致:done 和 skipped 都算当天已处理。 func portalDone(ci store.Checkin) bool { return ci.Status == "done" || ci.Status == "skipped" } func portalDayLabel(pd portalDay, month int) string { labelMonth := month if t, err := time.Parse("2006-01-02", pd.Date); err == nil { labelMonth = int(t.Month()) } label := fmt.Sprintf("%d 月 %d 日", labelMonth, pd.Day) if pd.Today { label += ",今天" } if pd.BlockText != "" { label += "," + pd.BlockTitle } if pd.Total > 0 { label += fmt.Sprintf(",共 %d 单,已完成 %d 单", pd.Total, pd.Done) } if pd.BlockText == "" && pd.Total == 0 { label += ",无排单" } return label } // portalQuery 用 url.Values 拼翻月链接,姓名含 & / 空格时也不会丢参数。 func portalQuery(tail, name, month string) string { q := url.Values{} q.Set("tail", tail) if name != "" { q.Set("name", name) } q.Set("cm", month) return "/customer?" + q.Encode() }