Files
subtrack/internal/web/server.go
T
subtrack a3fd817edc feat(v1.8): 包赛季模式、按次默认有效期、订阅删除
1) 包赛季(第三种计费方式)
- 套餐表单新增「包赛季」,套餐上直接填赛季开始日/结束日(必填,结束日不能早于开始日)
- 客户开通时自动带出赛季起止,不用再手填日期
- 日历上用横向色带标出整段赛季;赛季内任意一天点「完成」即整段完成
- 完成后不再进日历/待打,客户详情显示「赛季已完成 + 完成日期」
- 已过赛季结束日的套餐在客户开通下拉里置灰标「赛季已结束」,服务端同样拒绝开通

2) 按次默认有效期
- 套餐新增「按次默认有效期(天)」,开单时自动算出到期日(开始日 + 天数 − 1),仍可改

3) 订阅删除
- 已结束的订阅卡片在「重新启用」旁新增「删除」,二次确认后彻底删除该订阅及其打卡/追加/加急记录

4) 客户月历、日历与大屏同步识别赛季(色带、完成状态)

数据库兼容性:
- 仅新增列:packages.season / default_validity_days / season_start / season_end,subscriptions.season / season_done
- 没有改动原有 unit 的 CHECK 约束、没有重建表;老库启动自动 ALTER,可重复迁移

测试:
- 功能验证 13 项:赛季套餐创建、过期套餐不可选、开通带出日期、日历色带、完成整段后隐藏、按次自动到期、删除订阅
- 119 项验收回归全绿;go test ./... 通过;无头 Chrome 实拍 3 张(日历色带 / 客户赛季卡片 / 套餐表单)
- 版本号 1.7.0 → 1.8.0
2026-10-19 19:26:47 +08:00

511 lines
14 KiB
Go

// Package web 提供 HTTP 路由、会话、模板渲染与页面处理器。
package web
import (
"bytes"
"context"
"crypto/rand"
"embed"
"encoding/hex"
"fmt"
"html/template"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"daili/internal/domain"
"daili/internal/store"
)
//go:embed templates/_layout.html templates/_screen_layout.html templates/_partials.html templates/pages/*.html
var templateFS embed.FS
//go:embed static
var staticFS embed.FS
const (
cookieSession = "daili_sid"
cookieFlash = "daili_flash"
cookieTheme = "daili_theme"
sessionTTL = 7 * 24 * time.Hour
pageSize = 20
)
// Config 是服务配置。
type Config struct {
Addr string
DBPath string
BackupKeep int
BackupDir string
BackupEnabled bool
ScreenPublic bool
ConfigPath string
Version string
}
// Server 是应用服务。
type Server struct {
db *store.DB
cfg Config
tpl *renderer
handler http.Handler
started time.Time
}
type ctxKey int
const userCtxKey ctxKey = 1
// New 构造服务。
func New(db *store.DB, cfg Config) (*Server, error) {
r, err := newRenderer()
if err != nil {
return nil, err
}
s := &Server{db: db, cfg: cfg, tpl: r, started: time.Now()}
s.handler = s.routes()
return s, nil
}
// Handler 返回 HTTP 处理器。
func (s *Server) Handler() http.Handler { return s.handler }
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
sub, err := fs.Sub(staticFS, "static")
if err != nil {
panic(err)
}
mux.Handle("GET /static/", cacheControl(http.StripPrefix("/static/", http.FileServerFS(sub))))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("GET /login", s.handleLoginPage)
mux.HandleFunc("POST /login", s.handleLoginPost)
mux.HandleFunc("GET /setup", s.handleSetupPage)
mux.HandleFunc("POST /setup", s.handleSetupPost)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/calendar", http.StatusFound)
})
mux.HandleFunc("GET /calendar", s.auth(s.handleCalendar))
mux.HandleFunc("GET /customers", s.auth(s.handleCustomers))
mux.HandleFunc("GET /customers/new", s.auth(s.handleCustomerNewPage))
mux.HandleFunc("POST /customers", s.auth(s.handleCustomerCreate))
mux.HandleFunc("GET /customers/{id}", s.auth(s.handleCustomerDetail))
mux.HandleFunc("GET /customers/{id}/edit", s.auth(s.handleCustomerEditPage))
mux.HandleFunc("POST /customers/{id}/edit", s.auth(s.handleCustomerUpdate))
mux.HandleFunc("POST /customers/{id}/assignee", s.auth(s.handleCustomerAssignee))
mux.HandleFunc("POST /customers/{id}/archive", s.auth(s.handleCustomerArchive))
mux.HandleFunc("POST /customers/{id}/restore", s.auth(s.handleCustomerRestore))
mux.HandleFunc("POST /customers/{id}/delete", s.auth(s.handleCustomerDelete))
mux.HandleFunc("POST /customers/{id}/subscriptions", s.auth(s.handleSubCreate))
mux.HandleFunc("GET /subscriptions", s.auth(s.handleSubscriptions))
mux.HandleFunc("POST /subscriptions/{id}/extend", s.auth(s.handleSubExtend))
mux.HandleFunc("POST /subscriptions/{id}/pause", s.auth(s.handleSubAction("pause")))
mux.HandleFunc("POST /subscriptions/{id}/resume", s.auth(s.handleSubAction("resume")))
mux.HandleFunc("POST /subscriptions/{id}/close", s.auth(s.handleSubAction("close")))
mux.HandleFunc("POST /subscriptions/{id}/reopen", s.auth(s.handleSubAction("reopen")))
mux.HandleFunc("POST /subscriptions/{id}/delete", s.auth(s.handleSubDelete))
mux.HandleFunc("POST /subscriptions/{id}/season-done", s.auth(s.handleSeasonDone))
mux.HandleFunc("POST /subscriptions/{id}/checkin", s.auth(s.handleCheckinSave))
mux.HandleFunc("POST /checkins/{id}/delete", s.auth(s.handleCheckinDelete))
mux.HandleFunc("POST /checkins/batch", s.auth(s.handleCheckinBatch))
mux.HandleFunc("POST /checkins/quick", s.auth(s.handleQuickCheckin))
mux.HandleFunc("POST /tasks/urgent", s.auth(s.handleUrgentToggle))
mux.HandleFunc("GET /packages", s.auth(s.handlePackages))
mux.HandleFunc("GET /packages/new", s.auth(s.handlePackageNewPage))
mux.HandleFunc("POST /packages", s.auth(s.handlePackageCreate))
mux.HandleFunc("GET /packages/{id}/edit", s.auth(s.handlePackageEditPage))
mux.HandleFunc("POST /packages/{id}/edit", s.auth(s.handlePackageUpdate))
mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive))
mux.HandleFunc("GET /packages/{id}", s.auth(s.handlePackageDetail))
mux.HandleFunc("POST /packages/{id}/subscribe", s.auth(s.handlePackageBatchSubscribe))
if s.cfg.ScreenPublic {
// 大屏免登录:仅局域网部署时使用,内容为只读看板
mux.HandleFunc("GET /screen", s.handleScreen)
mux.HandleFunc("GET /api/screen/day", s.handleScreenDay)
} else {
mux.HandleFunc("GET /screen", s.auth(s.handleScreen))
mux.HandleFunc("GET /api/screen/day", s.auth(s.handleScreenDay))
}
mux.HandleFunc("GET /logs", s.auth(s.handleLogs))
mux.HandleFunc("GET /settings", s.auth(s.handleSettings))
mux.HandleFunc("POST /settings", s.auth(s.handleSettingsSave))
mux.HandleFunc("POST /settings/password", s.auth(s.handlePasswordChange))
mux.HandleFunc("POST /settings/users", s.auth(s.handleUserCreate))
mux.HandleFunc("POST /settings/users/{id}/toggle", s.auth(s.handleUserToggle))
mux.HandleFunc("POST /settings/users/{id}/color", s.auth(s.handleUserColor))
mux.HandleFunc("GET /settings/users/{id}/edit", s.auth(s.handleUserEditPage))
mux.HandleFunc("POST /settings/users/{id}/edit", s.auth(s.handleUserEditSave))
mux.HandleFunc("POST /settings/backup", s.auth(s.handleBackupNow))
mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport))
return s.noCacheHTML(mux)
}
func cacheControl(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=300")
h.ServeHTTP(w, r)
})
}
func (s *Server) noCacheHTML(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/static/") {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
}
h.ServeHTTP(w, r)
})
}
// ---------- 会话与鉴权 ----------
func randomToken() string {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
return hex.EncodeToString(b)
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *store.User) {
token := randomToken()
if err := s.db.CreateSession(token, u.ID, sessionTTL, clientIP(r)); err != nil {
log.Printf("创建会话失败: %v", err)
}
http.SetCookie(w, &http.Cookie{
Name: cookieSession, Value: token, Path: "/", HttpOnly: true,
SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()),
})
}
func (s *Server) userFromRequest(r *http.Request) *store.User {
c, err := r.Cookie(cookieSession)
if err != nil || c.Value == "" {
return nil
}
u, err := s.db.SessionUser(c.Value)
if err != nil {
return nil
}
return u
}
func (s *Server) currentUser(r *http.Request) *store.User {
if u, ok := r.Context().Value(userCtxKey).(*store.User); ok {
return u
}
return nil
}
func sameOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
u, err := url.Parse(origin)
if err != nil {
return false
}
return strings.EqualFold(u.Host, r.Host)
}
func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && !sameOrigin(r) {
http.Error(w, "跨站请求被拒绝", http.StatusForbidden)
return
}
u := s.userFromRequest(r)
if u == nil {
if r.Method == http.MethodGet {
next := url.QueryEscape(r.URL.RequestURI())
http.Redirect(w, r, "/login?next="+next, http.StatusFound)
return
}
http.Redirect(w, r, "/login", http.StatusFound)
return
}
ctx := context.WithValue(r.Context(), userCtxKey, u)
h(w, r.WithContext(ctx))
}
}
// ---------- 渲染 ----------
type renderer struct {
pages map[string]*template.Template
}
func newRenderer() (*renderer, error) {
funcs := template.FuncMap{
"seq": func(n int) []int {
out := make([]int, 0, n)
for i := 0; i < n; i++ {
out = append(out, i)
}
return out
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"money": func(cents int) string {
return fmt.Sprintf("%.0f", float64(cents)/100)
},
"pct": func(f float64) string { return fmt.Sprintf("%.2f", f) },
"md": func(s string) string { return mdOf(s) },
"dow": dowCN,
}
base, err := template.New("base").Funcs(funcs).ParseFS(templateFS,
"templates/_layout.html", "templates/_partials.html")
if err != nil {
return nil, err
}
screenBase, err := template.New("screen").Funcs(funcs).ParseFS(templateFS,
"templates/_screen_layout.html", "templates/_partials.html")
if err != nil {
return nil, err
}
pages := map[string]*template.Template{}
entries, err := fs.Glob(templateFS, "templates/pages/*.html")
if err != nil {
return nil, err
}
for _, p := range entries {
name := strings.TrimSuffix(strings.TrimPrefix(p, "templates/pages/"), ".html")
var baseSet *template.Template
if name == "screen" {
baseSet = screenBase
} else {
baseSet = base
}
clone, err := baseSet.Clone()
if err != nil {
return nil, err
}
if _, err := clone.ParseFS(templateFS, p); err != nil {
return nil, fmt.Errorf("解析模板 %s 失败: %w", p, err)
}
pages[name] = clone
}
return &renderer{pages: pages}, nil
}
func mdOf(s string) string {
if len(s) >= 10 {
return s[5:10]
}
return s
}
func dowCN(s string) string {
t, err := domain.ParseDate(s)
if err != nil {
return ""
}
return [...]string{"一", "二", "三", "四", "五", "六", "日"}[domain.WeekdayIndex(t)]
}
// page 是所有页面模板的根数据。
type page struct {
User *store.User
Title string
Nav string
Flash string
Error string
Today time.Time
TodayStr string
WarnDays int
Version string
Query string
Next string
DueToday int
Due3 int
Due7 int
Addr string
Theme string // "" = 跟随系统;light / dark
D any
}
func (s *Server) warnDays() int {
v, err := strconv.Atoi(s.db.Setting("warn_days", "3"))
if err != nil || v <= 0 {
return 3
}
return v
}
func (s *Server) screenRefresh() int {
v, err := strconv.Atoi(s.db.Setting("screen_refresh", "30"))
if err != nil || v < 5 {
return 30
}
return v
}
func (s *Server) newPage(r *http.Request, title, nav string) *page {
u := s.currentUser(r)
today := domain.Today()
p := &page{
User: u,
Title: title,
Nav: nav,
Today: today,
TodayStr: domain.Date(today),
WarnDays: s.warnDays(),
Version: s.cfg.Version,
Query: r.URL.Query().Get("q"),
Next: r.URL.Query().Get("next"),
Theme: themeFromRequest(r),
}
if u != nil {
if due, due3, due7, err := s.db.Badges(today); err == nil {
p.DueToday, p.Due3, p.Due7 = due, due3, due7
}
}
return p
}
// themeFromRequest 读取主题偏好:"" = 跟随系统,light / dark = 手动指定。
func themeFromRequest(r *http.Request) string {
c, err := r.Cookie(cookieTheme)
if err != nil {
return ""
}
switch c.Value {
case "light", "dark":
return c.Value
}
return ""
}
func (s *Server) popFlash(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie(cookieFlash)
if err != nil || c.Value == "" {
return ""
}
msg, _ := url.QueryUnescape(c.Value)
http.SetCookie(w, &http.Cookie{Name: cookieFlash, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
return msg
}
func setFlash(w http.ResponseWriter, msg string) {
http.SetCookie(w, &http.Cookie{
Name: cookieFlash, Value: url.QueryEscape(msg), Path: "/",
MaxAge: 30, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
}
func (s *Server) redirect(w http.ResponseWriter, r *http.Request, to, flash string) {
if flash != "" {
setFlash(w, flash)
}
http.Redirect(w, r, to, http.StatusSeeOther)
}
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, d *page) {
if d.User == nil {
d.User = s.currentUser(r)
}
if d.Flash == "" {
d.Flash = s.popFlash(w, r)
}
if d.Today.IsZero() {
d.Today = domain.Today()
d.TodayStr = domain.Date(d.Today)
}
if d.WarnDays == 0 {
d.WarnDays = s.warnDays()
}
tpl, ok := s.tpl.pages[name]
if !ok {
log.Printf("模板不存在: %s", name)
http.Error(w, "页面不存在", http.StatusInternalServerError)
return
}
layout := "layout"
if name == "screen" {
layout = "screenlayout"
}
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, layout, d); err != nil {
log.Printf("渲染 %s 失败: %v", name, err)
http.Error(w, "页面渲染失败: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = buf.WriteTo(w)
}
func (s *Server) fail(w http.ResponseWriter, r *http.Request, name string, msg string, d *page) {
if d == nil {
d = s.newPage(r, "出错了", "")
}
d.Error = msg
w.WriteHeader(http.StatusBadRequest)
s.render(w, r, name, d)
}
// ---------- 参数 ----------
func (s *Server) queryInt(r *http.Request, key string, def int) int {
v, err := strconv.Atoi(r.URL.Query().Get(key))
if err != nil {
return def
}
return v
}
func (s *Server) formInt(r *http.Request, key string, def int) int {
v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue(key)))
if err != nil {
return def
}
return v
}
func pathID(r *http.Request) int64 {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
return 0
}
return id
}
// backURL 取安全的返回地址。
func backURL(r *http.Request, def string) string {
if b := strings.TrimSpace(r.PostFormValue("back")); strings.HasPrefix(b, "/") && !strings.HasPrefix(b, "//") {
return b
}
if ref := r.Referer(); ref != "" {
if u, err := url.Parse(ref); err == nil && strings.EqualFold(u.Host, r.Host) {
p := u.RequestURI()
if strings.HasPrefix(p, "/") {
return p
}
}
}
return def
}