build / build-and-test (push) Successful in 3m26s
- 整段色块计数改为按当前格所在周期:cycleDone → 1/1,否则 0/1; 不再把全局 used/total 重复到每一格。 - 抽出 blockCellText / blockCellPercent,工作台、大屏、客户自助页共用同一口径; 通行证保持 0/1 / 1/1,深渊按所在周期 1/1 / 0/1。 - 大屏恢复通行证 / 深渊页签;screenCalSubs 补上缺失的 start(之前为零值, 导致大屏周期判定和色阶都算错),大屏格子现在显示与工作台一致的 1/1、0/1 以及 34%→4% 的周期色阶。 - 大屏色块样式:has-block / scr-block,亮暗两套都可读。 - 版本 1.11.5;README 的深渊说明同步,「工作台、大屏、客户自助页同一口径」。 验证:测试数据第一周期完成、第二周期未完成,工作台、 大屏、客户自助页均显示 20–26 为 1/1、27–31 为 0/1;周期浓度 34%/24%/14%/4%;gofmt / go vet / go test 全绿。
391 lines
9.6 KiB
Go
391 lines
9.6 KiB
Go
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()
|
||
}
|