- 路由:从 screen_public 拆出 portal_public(默认 true),关掉大屏不再连带 404; 设置页显示客户自助状态;/customer 与 /screen 各自独立。 - 查询:尾号只接受 4–8 位数字,服务端精确匹配,去掉 LIKE 模糊回退;按 IP 40 次/分钟限流;页面 noindex。 - 语义:修正「今日待完成」把已完成数当待完成数的问题;0/0 改为「今日无排单」; 无订阅走空态;删掉进度条和重复的今日文案,只留一个状态。 - 订阅:复用管理端 SubView / calSub;通行证 1/1、深渊 x/y 周期;过期、暂停、 按次用尽、跳过都有独立口径;「最近到期」只取未来日期。 - 日历:与主日历一致,只在本月格显示任务;通行证/深渊整段色块(深渊按周期 减淡),日常任务用空心/实心点;每格带读屏描述;today 加「今天」文字标记。 - 错误:只渲染一次并放回内容容器,字段加 aria-invalid;删掉顶部重复横幅。 - 多客户:显示掩码姓名 + 卡内补姓名表单;姓名回填;翻月用 url.Values 编码; 1 位尾号不触发查询;只有空态/补姓名时 autofocus。 - UI:品牌头、可折叠「查询其他尾号」、订阅全宽列表、状态统一用现有 chip、 月份按钮 44px、去掉卡中卡 / opacity / 满屏 0/1。 - 版本 1.11.3;README 增加 4.4 与 portal_public 配置说明。 验证:gofmt / go vet / go test 全绿;portal_public=true 时 /customer 200、 false 时 404;screen_public=false + portal_public=true 时 /customer 200、 /screen 302;尾号 2 与 % 被拒;2222 多客户掩码;9900 日常点、3321 通行证 1/1、6677 深渊 1/4 + 已到期订阅;明暗 1440 与 390 截图无对比度/溢出问题。
This commit is contained in:
+262
-111
@@ -2,15 +2,19 @@ package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"daili/internal/domain"
|
||||
"daili/internal/store"
|
||||
)
|
||||
|
||||
// portalDay 自助查询月历中的一天。
|
||||
// portalDay 客户自助月历中的一天:日常任务按 done/total 计,通行证/深渊按整段色块展示。
|
||||
type portalDay struct {
|
||||
Date string
|
||||
Day int
|
||||
@@ -18,90 +22,137 @@ type portalDay struct {
|
||||
Today bool
|
||||
Total int
|
||||
Done int
|
||||
// 通行证 / 深渊色块
|
||||
BlockColor string
|
||||
BlockText string
|
||||
BlockTitle string
|
||||
BlockPercent int
|
||||
// Label 是读屏用的整格描述
|
||||
Label string
|
||||
}
|
||||
|
||||
// portalSub 自助查询里展示的订阅(隐藏备注、打手、价格)。
|
||||
// portalSub 客户自助页里的一个订阅;复用管理端 SubView 的状态/剩余文案。
|
||||
type portalSub struct {
|
||||
Name string
|
||||
UnitLabel string
|
||||
Status string
|
||||
Period string
|
||||
TodayDone bool
|
||||
SubView
|
||||
TodayLabel string
|
||||
TodayClass string
|
||||
}
|
||||
|
||||
// portalAsk 同一尾号命中多个客户时要求补姓名。
|
||||
type portalAsk struct {
|
||||
Count int
|
||||
Names []string
|
||||
Tail string
|
||||
Count int
|
||||
Masked []string
|
||||
}
|
||||
|
||||
// portalView 查询结果。
|
||||
// portalView 客户自助查询结果。
|
||||
type portalView struct {
|
||||
Tail string
|
||||
Name string
|
||||
Game string
|
||||
Subs []portalSub
|
||||
Weeks [][]portalDay
|
||||
MonthTitle string
|
||||
Prev string
|
||||
Next string
|
||||
TodayTotal int
|
||||
TodayDone int
|
||||
AllDone bool
|
||||
ActiveSubs int
|
||||
Expiring string
|
||||
TodayPct int
|
||||
Count int
|
||||
Names []string
|
||||
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
|
||||
}
|
||||
|
||||
list, err := s.db.CustomersByTail(tail)
|
||||
if err != nil {
|
||||
p.Error = "读取客户失败,请稍后再试"
|
||||
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
|
||||
}
|
||||
list = filterActive(list)
|
||||
if len(list) == 0 {
|
||||
p.Error = "没有找到这个尾号的客户,请核对尾号"
|
||||
p.InlineError = "没有找到这个尾号的客户,请核对尾号"
|
||||
p.InvalidField = "tail"
|
||||
s.render(w, r, "portal", p)
|
||||
return
|
||||
}
|
||||
if len(list) > 1 {
|
||||
if name == "" {
|
||||
names := make([]string, 0, len(list))
|
||||
for _, c := range list {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
p.D = &portalAsk{Count: len(list), Names: names}
|
||||
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.Error = "尾号对得上,但姓名不匹配,请再确认一下"
|
||||
p.InlineError = "尾号对得上,但姓名不匹配,请再确认一下"
|
||||
p.InvalidField = "name"
|
||||
s.render(w, r, "portal", p)
|
||||
return
|
||||
}
|
||||
if len(matched) > 1 {
|
||||
names := make([]string, 0, len(matched))
|
||||
for _, c := range matched {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
p.D = &portalAsk{Count: len(matched), Names: names}
|
||||
p.D = &portalAsk{Tail: tail, Count: len(matched), Masked: maskNames(matched)}
|
||||
s.render(w, r, "portal", p)
|
||||
return
|
||||
}
|
||||
@@ -113,16 +164,6 @@ func (s *Server) handleCustomerPortal(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, r, "portal", p)
|
||||
}
|
||||
|
||||
func filterActive(list []store.Customer) []store.Customer {
|
||||
out := list[:0]
|
||||
for _, c := range list {
|
||||
if !c.Archived {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func matchByName(list []store.Customer, name string) []store.Customer {
|
||||
needle := strings.ToLower(strings.TrimSpace(name))
|
||||
out := make([]store.Customer, 0, len(list))
|
||||
@@ -134,10 +175,37 @@ func matchByName(list []store.Customer, name string) []store.Customer {
|
||||
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 {
|
||||
@@ -150,58 +218,66 @@ func (s *Server) buildPortal(c store.Customer, tail, name, month string) *portal
|
||||
start := firstDay.AddDate(0, 0, -((int(firstDay.Weekday()) + 6) % 7))
|
||||
end := lastDay.AddDate(0, 0, 6-((int(lastDay.Weekday())+6)%7))
|
||||
|
||||
subs, _ := s.db.SubsByCustomer(c.ID)
|
||||
active := make([]store.Subscription, 0, len(subs))
|
||||
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 {
|
||||
if sub.Status != domain.StatusClosed {
|
||||
active = append(active, sub)
|
||||
cs := calSub{
|
||||
sub: sub,
|
||||
start: domain.ParseDateOr(sub.StartDate, today),
|
||||
eval: sub.Eval(today, warn),
|
||||
pauses: pauses[sub.ID],
|
||||
checkins: cks[sub.ID],
|
||||
}
|
||||
}
|
||||
|
||||
cov := func(sub store.Subscription, date string) bool {
|
||||
if date < sub.StartDate {
|
||||
return false
|
||||
}
|
||||
if sub.EndDate == "" {
|
||||
return true
|
||||
}
|
||||
return date <= sub.EndDate
|
||||
}
|
||||
|
||||
// 打卡记录
|
||||
cks, _ := s.db.CheckinsBetween(domain.Date(start), domain.Date(end))
|
||||
|
||||
v := &portalView{Tail: c.Tail, Name: c.Name, Game: c.GameName, ActiveSubs: len(active)}
|
||||
v.MonthTitle = fmt.Sprintf("%d 年 %d 月", year, mon)
|
||||
q := "?tail=" + tail
|
||||
if name != "" {
|
||||
q += "&name=" + name
|
||||
}
|
||||
v.Prev = q + "&cm=" + firstDay.AddDate(0, -1, 0).Format("2006-01")
|
||||
v.Next = q + "&cm=" + firstDay.AddDate(0, 1, 0).Format("2006-01")
|
||||
|
||||
for _, sub := range active {
|
||||
label := "按日"
|
||||
if sub.Season {
|
||||
label = "包赛季"
|
||||
} else if sub.Unit == domain.UnitCount {
|
||||
label = "按次"
|
||||
}
|
||||
period := sub.StartDate
|
||||
if sub.EndDate != "" {
|
||||
period += " → " + sub.EndDate
|
||||
} else {
|
||||
period += " 起 · 无有效期"
|
||||
cs.hasEnd = true
|
||||
cs.end = domain.ParseDateOr(sub.EndDate, today)
|
||||
}
|
||||
ps := portalSub{Name: sub.PackageName, UnitLabel: label, Status: sub.Status, Period: period}
|
||||
if m, ok := cks[sub.ID]; ok {
|
||||
if ck, ok2 := m[todayStr]; ok2 && ck.Status == "done" {
|
||||
ps.TodayDone = true
|
||||
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 != "" && (v.Expiring == "" || sub.EndDate < v.Expiring) {
|
||||
v.Expiring = sub.EndDate
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,20 +288,33 @@ func (s *Server) buildPortal(c store.Customer, tail, name, month string) *portal
|
||||
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}
|
||||
for _, sub := range active {
|
||||
if !cov(sub, ds) {
|
||||
continue
|
||||
}
|
||||
pd.Total++
|
||||
if m, ok := cks[sub.ID]; ok {
|
||||
if ck, ok2 := m[ds]; ok2 && ck.Status == "done" {
|
||||
// 与主日历一致:只在本月格子里显示任务,前后月只做日期占位。
|
||||
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 = fmt.Sprintf("%d/%d", cs.blockDone(), cs.blockTotal())
|
||||
pd.BlockTitle = sub.PackageName + " · " + pd.BlockText
|
||||
pd.BlockPercent = 18
|
||||
if sub.Abyss && sub.CycleDays > 0 {
|
||||
idx := domain.CycleIndex(cs.start, d, sub.CycleDays)
|
||||
pd.BlockPercent = abyssBlockPercent(idx, sub.TotalCount)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !cs.covers(ds) {
|
||||
continue
|
||||
}
|
||||
pd.Total++
|
||||
if ci, ok := cks[sub.ID][ds]; ok && portalDone(ci) {
|
||||
pd.Done++
|
||||
}
|
||||
}
|
||||
}
|
||||
if pd.Today {
|
||||
v.TodayTotal, v.TodayDone = pd.Total, pd.Done
|
||||
}
|
||||
pd.Label = portalDayLabel(pd, mon)
|
||||
week = append(week, pd)
|
||||
if len(week) == 7 {
|
||||
v.Weeks = append(v.Weeks, week)
|
||||
@@ -235,9 +324,71 @@ func (s *Server) buildPortal(c store.Customer, tail, name, month string) *portal
|
||||
if len(week) > 0 {
|
||||
v.Weeks = append(v.Weeks, week)
|
||||
}
|
||||
v.AllDone = v.TodayTotal > 0 && v.TodayDone >= v.TodayTotal
|
||||
if v.TodayTotal > 0 {
|
||||
v.TodayPct = v.TodayDone * 100 / v.TodayTotal
|
||||
|
||||
// 今日进度:整段型订阅算 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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user