Files
rond-with-you/internal/web/server.go
T
tamakyi 152af81f6a 编辑页:新增「重复到访」卡片 —— 以基线(手机包)为参照清掉站点侧的历史副本
站点库里有一批「到达时间 + 地点与手机包里某条完全相同、但 UUID 不同」的到访副本,
合并/差异按 UUID 认人会把它们当成备份新增,导出到 rond 就成了成对的两条重复记录。
判定与 tools/dedup_site_visits.py 同一套语义:UUID 是前提(只按时间+地点判会误删手机包
自身合法的成对条目);基线跟本站一条都对不上时只给说明、不给按钮。清理在单事务里删
副本 + 其位移/天气/留档,并把指向副本的 ZMERGEDTO/ZPARENT 改指到保留项——只动本来
就有的那个键,工具那版会凭空补出一个 ZMERGEDTO(真机里是「已并入别处」,会白丢记录)。

顺带修一个「一补录天气就整包导出失败」的洞:rond 导出把 weather.is_daylight 扫进裸 bool,
而站点「新增天气」不写这一列 ⇒ NULL ⇒ sql.Scan 报错。改成 COALESCE(is_daylight,false),
与导入侧读真机包同口径。

验收:301 PASS / 0 FAIL / 0 SKIP(新增 4 个用例,含变异验证:去掉 UUID 前提/改回
create_if_missing=true/不删副本位移,三种改法都被用例抓到)。
2026-09-29 19:17:29 +08:00

559 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"context"
"database/sql"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"net/http"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"rond-with-you/internal/auth"
"rond-with-you/internal/captcha"
"rond-with-you/internal/config"
"rond-with-you/internal/media"
"rond-with-you/internal/notes"
"rond-with-you/internal/setting"
"rond-with-you/internal/stats"
"rond-with-you/internal/votes"
)
//go:embed templates/*.html templates/admin/*.html
var tmplFS embed.FS
//go:embed static
var staticFS embed.FS
// pageFiles 每个页面单独成组解析:页面文件都定义 "content",
// 与 layout.html 一起构成一个独立模板集,避免定义名冲突。
var pageFiles = map[string]struct {
files []string
entry string
}{
"home": {[]string{"templates/layout.html", "templates/home.html"}, "layout"},
"map": {[]string{"templates/layout.html", "templates/map.html"}, "layout"},
"places": {[]string{"templates/layout.html", "templates/places.html"}, "layout"},
"place": {[]string{"templates/layout.html", "templates/place.html"}, "layout"},
"timeline": {[]string{"templates/layout.html", "templates/timeline.html"}, "layout"},
"stats": {[]string{"templates/layout.html", "templates/stats.html"}, "layout"},
"report": {[]string{"templates/layout.html", "templates/report.html"}, "layout"},
"admin/index": {[]string{"templates/layout.html", "templates/admin/index.html"}, "layout"},
"admin/settings": {[]string{"templates/layout.html", "templates/admin/settings.html"}, "layout"},
"admin/password": {[]string{"templates/layout.html", "templates/admin/password.html"}, "layout"},
"admin/login": {[]string{"templates/admin/login.html"}, "login"},
"admin/twofa-login": {[]string{"templates/admin/twofa-login.html"}, "login2"},
"admin/twofa": {[]string{"templates/layout.html", "templates/admin/twofa.html"}, "layout"},
"admin/fog-mask": {[]string{"templates/layout.html", "templates/admin/fog-mask.html"}, "layout"},
"admin/fog-preview": {[]string{"templates/layout.html", "templates/admin/fog-preview.html"}, "layout"},
"admin/backup-preview": {[]string{"templates/layout.html", "templates/admin/backup-preview.html"}, "layout"},
"admin/fillup": {[]string{"templates/layout.html", "templates/admin/fillup.html"}, "layout"},
"admin/notes": {[]string{"templates/layout.html", "templates/admin/notes.html"}, "layout"},
"admin/edit": {[]string{"templates/layout.html", "templates/admin/edit.html"}, "layout"},
"admin/topics": {[]string{"templates/layout.html", "templates/admin/topics.html"}, "layout"},
"admin/photos": {[]string{"templates/layout.html", "templates/admin/photos.html"}, "layout"},
"topics": {[]string{"templates/layout.html", "templates/topics.html"}, "layout"},
"topic": {[]string{"templates/layout.html", "templates/topic.html"}, "layout"},
}
type pageSet struct {
t *template.Template
entry string
}
type Server struct {
cfg *config.Config
db *sql.DB
q stats.DB
auth *auth.Store
sets *setting.Store
notes *notes.Store
votes *votes.Store
pages map[string]pageSet
mux *http.ServeMux
cookie string
tiles tileSet
tileCache *tileCache
// fogTiles 缓存渲染好的迷雾瓦片:每张都要现画逐格位图再 PNG 编码,
// 而内容只随「迷雾版本 + 遮罩 + 底图坐标系」变化。
fogTiles *tileCache
// masks 缓存遮罩列表:它不在 fogIndex 里,不然每张瓦片都要查一次库。
masks maskCache
// captcha 存登录验证码的答案(内存、一次性、带过期)
captcha *captcha.Store
// loginGuard 是登录失败的短锁(按用户名指数退避),验证码之外的又一道闸。
// 两步验证那一步也用同一个实例,但键带 "2fa:" 前缀,两边计数互不干扰。
loginGuard *auth.Guard
// totpReplay 记住最近用过的验证码:同一个码在 ±1 步窗口内只能换一次会话
totpReplay *auth.CodeReplay
// media 管地点图片写哪(本地磁盘 / 对象存储)与怎么取址
media *media.Store
}
func New(cfg *config.Config, db *sql.DB) (*Server, error) {
initAssetVersion(cfg.Debug)
pages := make(map[string]pageSet, len(pageFiles))
for key, def := range pageFiles {
t, err := template.New(filepath.Base(def.files[0])).Funcs(templateFuncs()).
ParseFS(tmplFS, def.files...)
if err != nil {
return nil, fmt.Errorf("解析模板 %s 失败: %w", key, err)
}
pages[key] = pageSet{t: t, entry: def.entry}
}
s := &Server{
cfg: cfg,
db: db,
q: stats.DB{DB: db},
auth: &auth.Store{DB: db, Secret: []byte(cfg.SecretKey), Days: cfg.SessionDays},
sets: &setting.Store{DB: db},
notes: &notes.Store{DB: db},
votes: &votes.Store{DB: db},
pages: pages,
mux: http.NewServeMux(),
cookie: "rond_session",
tiles: buildTileSet(cfg),
tileCache: newTileCache(1024),
fogTiles: newTileCache(512),
captcha: captcha.NewStore(10 * time.Minute),
loginGuard: auth.NewGuard(),
totpReplay: auth.NewCodeReplay(),
media: media.New(cfg),
}
s.routes()
return s, nil
}
// guardPage 拦截「站长已关闭」的公开页面:管理员照常访问,访客拿到 404。
// withUser 在 mux 之前运行,这里能直接拿到登录态。
func (s *Server) guardPage(nav string, next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if userFrom(r.Context()) == nil {
if sets, err := s.sets.Load(r.Context()); err == nil && !sets.HasPage(nav) {
s.pausePage(w, http.StatusNotFound, "🔒", "该页面未对访客开放",
"站长还没有把这一页开放给访客。", 0)
return
}
}
next.ServeHTTP(w, r)
})
}
func (s *Server) Handler() http.Handler {
return s.recover(gzipHandler(s.logRequests(s.withUser(s.csrfOriginGuard(s.mux)))))
}
// cacheStatic 给带了当前版本号的静态资源加长期强缓存。
// 版本号随编译产物变化(静态文件内容哈希),旧链接即使被缓存也不会命中新版本。
func (s *Server) cacheStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if v := r.URL.Query().Get("v"); v != "" && v == assetVersion {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
next.ServeHTTP(w, r)
})
}
// withUser 为所有请求解析登录态。公开页面也要知道访问者是访客还是站长,
// 才能决定是否渲染「含家/工作」这类只给自己看的开关。
// 未携带会话 cookie 时不会查库,匿名访问没有额外开销。
func (s *Server) withUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if u, err := s.sessionUser(r); err == nil && u != nil {
r = r.WithContext(context.WithValue(r.Context(), userKey{}, u))
}
next.ServeHTTP(w, r)
})
}
func (s *Server) routes() {
// 静态资源:调试模式下直接读磁盘,改样式/脚本不用重新编译。
// 页面链接都带 ?v=<版本>,版本一致才给一年强缓存,重新编译后版本变了自然失效。
if s.cfg.Debug {
s.mux.Handle("GET /static/", s.cacheStatic(
http.StripPrefix("/static/", http.FileServer(http.Dir("internal/web/static")))))
} else {
sub, _ := fs.Sub(staticFS, "static")
s.mux.Handle("GET /static/", s.cacheStatic(
http.StripPrefix("/static/", http.FileServer(http.FS(sub)))))
}
// 公开页面:受「公开页面」开关控制,关闭后仅管理员可访问
s.mux.HandleFunc("GET /{$}", s.home)
s.mux.Handle("GET /map", s.guardPage("map", s.mapPage))
s.mux.Handle("GET /places", s.guardPage("places", s.placesPage))
s.mux.HandleFunc("GET /places/{id}", s.placePage)
s.mux.Handle("GET /timeline", s.guardPage("timeline", s.timelinePage))
s.mux.Handle("GET /stats", s.guardPage("stats", s.statsPage))
s.mux.HandleFunc("GET /api/points", s.apiPoints)
s.mux.HandleFunc("GET /api/track", s.apiTrack)
s.mux.HandleFunc("GET /api/cities", s.apiCityAgg)
s.mux.HandleFunc("GET /api/gpx", s.apiGpx)
s.mux.HandleFunc("GET /api/votes", s.apiVotes)
s.mux.HandleFunc("POST /api/vote", s.apiVote)
s.mux.HandleFunc("POST /api/verdict", s.requireAdmin(s.apiVerdict))
s.mux.HandleFunc("GET /api/map-config", s.mapConfig)
// 地理编码走服务端代理,高德密钥不出现在前端源码里;仅管理员可用,避免配额被外部消耗
s.mux.HandleFunc("GET /api/geocode", s.requireAdmin(s.apiGeocode))
s.mux.HandleFunc("GET /api/coords", s.requireAdmin(s.apiCoords))
s.mux.HandleFunc("POST /api/regeo", s.requireAdmin(s.apiRegeo))
s.mux.HandleFunc("GET /tiles/{layer}/{z}/{x}/{y}", s.handleTile)
s.mux.HandleFunc("GET /tiles/fog/{z}/{x}/{y}", s.handleFogTile)
// 专题:把某段时间 / 某个区域的足迹打包成可对外展示的页面。
// 不做页面级开关——访客能不能看由「每个专题的展示开关」决定(详情页对未展示的专题 404)。
s.mux.HandleFunc("GET /admin/topics", s.requireAdmin(s.topicsAdminPage))
s.mux.HandleFunc("POST /admin/topics/save", s.requireAdmin(s.adminTopicSave))
s.mux.HandleFunc("POST /admin/topics/delete", s.requireAdmin(s.adminTopicDelete))
s.mux.HandleFunc("POST /admin/topics/toggle", s.requireAdmin(s.adminTopicToggle))
s.mux.HandleFunc("GET /topics", s.topicsListPage)
s.mux.HandleFunc("GET /topics/{id}", s.topicPage)
s.mux.Handle("GET /report", s.guardPage("report", s.reportPage))
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := s.db.PingContext(r.Context()); err != nil {
http.Error(w, "db down", http.StatusServiceUnavailable)
return
}
io.WriteString(w, "ok")
})
// 后台
s.mux.HandleFunc("GET /captcha", s.captchaImage)
s.mux.HandleFunc("GET /admin/login", s.loginPage)
s.mux.HandleFunc("POST /admin/login", s.loginSubmit)
s.mux.HandleFunc("POST /admin/login/twofa", s.login2FA)
s.mux.HandleFunc("POST /admin/logout", s.logout)
s.mux.HandleFunc("GET /admin/twofa", s.requireAdmin(s.twofaPage))
s.mux.HandleFunc("POST /admin/twofa/setup", s.requireAdmin(s.twofaSetup))
s.mux.HandleFunc("POST /admin/twofa/confirm", s.requireAdmin(s.twofaConfirm))
s.mux.HandleFunc("POST /admin/twofa/disable", s.requireAdmin(s.twofaDisable))
s.mux.HandleFunc("GET /admin", s.requireAdmin(s.adminHome))
s.mux.HandleFunc("POST /admin/upload", s.requireAdmin(s.adminUpload))
s.mux.HandleFunc("POST /admin/preview", s.requireAdmin(s.adminBackupPreview))
s.mux.HandleFunc("GET /admin/preview/{token}", s.requireAdmin(s.adminBackupPreviewPage))
s.mux.HandleFunc("POST /admin/preview/{token}/merge", s.requireAdmin(s.adminBackupMerge))
s.mux.HandleFunc("POST /admin/datasets/{id}/activate", s.requireAdmin(s.adminActivate))
s.mux.HandleFunc("POST /admin/datasets/{id}/delete", s.requireAdmin(s.adminDelete))
s.mux.HandleFunc("POST /admin/place/note", s.requireAdmin(s.adminPlaceNote))
s.mux.HandleFunc("POST /admin/place/vote", s.requireAdmin(s.adminPlaceVote))
// 地点图片:字节由浏览器转成 WebP 后发过来,这里只负责收下、落库、取址
s.mux.HandleFunc("POST /admin/place/images", s.requireAdmin(s.adminPlaceImageUpload))
s.mux.HandleFunc("POST /admin/place/images/delete", s.requireAdmin(s.adminPlaceImageDelete))
// 本地存储的图片直出;对象存储的图走公开域名,不经过这里
s.mux.HandleFunc("GET /media/", s.serveMedia)
s.mux.HandleFunc("POST /admin/notes/bulk", s.requireAdmin(s.adminNotesBulk))
s.mux.HandleFunc("POST /admin/notes/rond", s.requireAdmin(s.adminNotesRond))
s.mux.HandleFunc("GET /admin/notes", s.requireAdmin(s.notesPage))
// 数据编辑:手动补录记录点,改完可导出回 rondbackup
s.mux.HandleFunc("GET /admin/edit", s.requireAdmin(s.editPage))
s.mux.HandleFunc("POST /admin/edit/place", s.requireAdmin(s.editPlaceCreate))
s.mux.HandleFunc("POST /admin/edit/place/update", s.requireAdmin(s.editPlaceUpdate))
s.mux.HandleFunc("POST /admin/edit/place/delete", s.requireAdmin(s.editPlaceDelete))
s.mux.HandleFunc("POST /admin/edit/visit/update", s.requireAdmin(s.editVisitUpdate))
s.mux.HandleFunc("POST /admin/edit/activity", s.requireAdmin(s.editActivityCreate))
s.mux.HandleFunc("POST /admin/edit/activity/update", s.requireAdmin(s.editActivityUpdate))
s.mux.HandleFunc("POST /admin/edit/activity/delete", s.requireAdmin(s.editActivityDelete))
s.mux.HandleFunc("POST /admin/edit/activity/merge", s.requireAdmin(s.editActivityMerge))
s.mux.HandleFunc("POST /admin/edit/category/rename", s.requireAdmin(s.editCategoryRename))
s.mux.HandleFunc("POST /admin/edit/category/clear", s.requireAdmin(s.editCategoryClear))
s.mux.HandleFunc("POST /admin/edit/category/label", s.requireAdmin(s.editCategoryLabel))
// 照片导入:EXIF 由浏览器读(不上传照片),确认后只把元数据提交上来落库
s.mux.HandleFunc("GET /admin/photos", s.requireAdmin(s.adminPhotosPage))
s.mux.HandleFunc("POST /admin/photos/import", s.requireAdmin(s.adminPhotosImport))
s.mux.HandleFunc("POST /admin/edit/visit/delete", s.requireAdmin(s.editVisitDelete))
s.mux.HandleFunc("POST /admin/edit/visit/bookmark", s.requireAdmin(s.adminVisitBookmark))
// 重复到访:清掉站点侧跟手机包重复的历史副本(判定见 edit_duplicates.go)
s.mux.HandleFunc("POST /admin/edit/dup/clean", s.requireAdmin(s.editDuplicateVisitsApply))
s.mux.HandleFunc("POST /admin/edit/movement", s.requireAdmin(s.editMovementCreate))
s.mux.HandleFunc("POST /admin/edit/movement/update", s.requireAdmin(s.editMovementUpdate))
s.mux.HandleFunc("POST /admin/edit/movement/delete", s.requireAdmin(s.editMovementDelete))
s.mux.HandleFunc("POST /admin/edit/weather", s.requireAdmin(s.editWeatherCreate))
s.mux.HandleFunc("POST /admin/gpx", s.requireAdmin(s.adminGpxUpload))
s.mux.HandleFunc("POST /admin/gpx/delete", s.requireAdmin(s.adminGpxDelete))
s.mux.HandleFunc("POST /admin/fog/preview", s.requireAdmin(s.adminFogPreview))
s.mux.HandleFunc("GET /admin/fog/preview/{token}", s.requireAdmin(s.adminFogPreviewPage))
s.mux.HandleFunc("GET /admin/fog/preview/{token}/{z}/{x}/{y}", s.requireAdmin(s.adminFogDiffTile))
s.mux.HandleFunc("POST /admin/fog", s.requireAdmin(s.adminFogUpload))
s.mux.HandleFunc("POST /admin/fog/clear", s.requireAdmin(s.adminFogClear))
s.mux.HandleFunc("GET /admin/fog/export", s.requireAdmin(s.adminFogExport))
s.mux.HandleFunc("POST /admin/places/backfill", s.requireAdmin(s.adminBackfillPlaces))
s.mux.HandleFunc("GET /admin/fog/mask", s.requireAdmin(s.fogMaskPage))
s.mux.HandleFunc("POST /admin/fog/mask/save", s.requireAdmin(s.fogMaskSave))
s.mux.HandleFunc("POST /admin/fog/mask/delete", s.requireAdmin(s.fogMaskDelete))
s.mux.HandleFunc("GET /admin/export/places.geojson", s.requireAdmin(s.exportGeoJSON))
s.mux.HandleFunc("GET /admin/export/places.gpx", s.requireAdmin(s.exportGPX))
s.mux.HandleFunc("GET /admin/export/rondbackup", s.requireAdmin(s.exportRondbackup))
s.mux.HandleFunc("GET /admin/fillup", s.requireAdmin(s.adminFillupPage))
s.mux.HandleFunc("POST /admin/fillup/apply", s.requireAdmin(s.adminFillupApply))
s.mux.HandleFunc("POST /admin/fillup/amap", s.requireAdmin(s.adminFillupAmap))
s.mux.HandleFunc("GET /admin/settings", s.requireAdmin(s.settingsPage))
s.mux.HandleFunc("POST /admin/settings", s.requireAdmin(s.settingsSubmit))
s.mux.HandleFunc("GET /admin/password", s.requireAdmin(s.passwordPage))
s.mux.HandleFunc("POST /admin/password", s.requireAdmin(s.passwordSubmit))
s.mux.HandleFunc("POST /admin/username", s.requireAdmin(s.usernameSubmit))
// 兜底:没匹配上任何路由的地址(打错的链接、爬虫乱扫)也给站点样式的中间页。
// 不注册这条的话走的是 ServeMux 默认响应,白底一行 `404 page not found`,看着像站点坏了。
// 更具体的模式优先级更高,所以静态资源、瓦片、图片这些不受影响;
// 瓦片/图片那几处仍然保留纯文本 404 —— 那些是给浏览器和爬虫取的资源,不是给人看的页面。
s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
s.notFoundPage(w, "", "")
})
}
// ---------- 中间件 ----------
func (s *Server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
if s.cfg.Debug || strings.HasPrefix(r.URL.Path, "/admin") {
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
}
})
}
// headerSent 记录响应首部是否已经提交,供 recover 判断「还能不能写错误页」。
// 首部一旦提交,http.Error 只能再 WriteHeader 一次(Go 记一条 superfluous
// response.WriteHeader)并把错误体追加到半个页面后面,比不写更糟。
type headerSent struct {
http.ResponseWriter
sent bool
}
func (h *headerSent) WriteHeader(code int) {
h.sent = true
h.ResponseWriter.WriteHeader(code)
}
func (h *headerSent) Write(b []byte) (int, error) {
h.sent = true
return h.ResponseWriter.Write(b)
}
// Unwrap 让 http.NewResponseController 能穿过这层拿到真正的 ResponseWriter
// (SetWriteDeadline / Flush / Hijack 都靠逐层解包)。
func (h *headerSent) Unwrap() http.ResponseWriter { return h.ResponseWriter }
func (s *Server) recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hw := &headerSent{ResponseWriter: w}
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic %s %s: %v", r.Method, r.URL.Path, rec)
if hw.sent {
// 已经发出去了,再写错误页只会污染响应;真正的补救是断开连接。
return
}
http.Error(hw, "服务器内部错误", http.StatusInternalServerError)
}
}()
next.ServeHTTP(hw, r)
})
}
// sessionUser 从签名 cookie 还原登录态;口令变更后旧会话自动失效。
func (s *Server) sessionUser(r *http.Request) (*auth.User, error) {
c, err := r.Cookie(s.cookie)
if err != nil || c.Value == "" {
return nil, nil
}
uidStr, _, ok := strings.Cut(c.Value, "|")
if !ok {
return nil, nil
}
uid, err := strconv.ParseInt(uidStr, 10, 64)
if err != nil {
return nil, nil
}
u, err := s.auth.UserByID(r.Context(), uid)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
if _, ok := s.auth.ParseToken(c.Value, u.Hash); !ok {
return nil, nil
}
return u, nil
}
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
if u == nil {
http.Redirect(w, r, "/admin/login?next="+path.Clean(r.URL.Path), http.StatusSeeOther)
return
}
next(w, r)
}
}
type userKey struct{}
func userFrom(ctx context.Context) *auth.User {
u, _ := ctx.Value(userKey{}).(*auth.User)
return u
}
// cookieSecure 判断这次响应能不能给会话 cookie 打 Secure。
//
// 不能无条件打:本机 http 调试、以及本仓库的 CDP 验证流程都跑在 http 上,
// 打了 Secure 浏览器就不回传 cookie,等于谁都登不进去。所以按「这次请求是不是
// 走的 https」判断:直连看 r.TLS,在反向代理后面看 X-Forwarded-Proto(只取首段)。
func cookieSecure(r *http.Request) bool {
if r.TLS != nil {
return true
}
proto := r.Header.Get("X-Forwarded-Proto")
if i := strings.IndexByte(proto, ','); i >= 0 {
proto = proto[:i]
}
return strings.EqualFold(strings.TrimSpace(proto), "https")
}
func (s *Server) login(w http.ResponseWriter, r *http.Request, userID int64, hash string) {
http.SetCookie(w, &http.Cookie{
Name: s.cookie,
Value: s.auth.SignToken(userID, hash),
Path: "/",
HttpOnly: true,
Secure: cookieSecure(r),
SameSite: http.SameSiteLaxMode,
MaxAge: s.cfg.SessionDays * 86400,
})
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: s.cookie, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: cookieSecure(r),
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// ---------- 渲染 ----------
// render 渲染指定页面(key 见 pageFiles)。
func (s *Server) render(w http.ResponseWriter, key string, data any) {
set, err := s.pageSet(key)
if err != nil {
log.Printf("加载页面模板 %s 失败: %v", key, err)
http.Error(w, "页面不存在", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := set.t.ExecuteTemplate(w, set.entry, data); err != nil {
log.Printf("渲染 %s 失败: %v", key, err)
}
}
func (s *Server) renderStatus(w http.ResponseWriter, code int, key string, data any) {
set, err := s.pageSet(key)
if err != nil {
http.Error(w, "页面不存在", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
if err := set.t.ExecuteTemplate(w, set.entry, data); err != nil {
log.Printf("渲染 %s 失败: %v", key, err)
}
}
// pageSet 返回模板集;调试模式下每次从磁盘重新解析,改完模板刷新即可见效。
func (s *Server) pageSet(key string) (pageSet, error) {
def, ok := pageFiles[key]
if !ok {
return pageSet{}, fmt.Errorf("未知页面模板 %s", key)
}
if !s.cfg.Debug {
return s.pages[key], nil
}
files := make([]string, len(def.files))
for i, f := range def.files {
// 嵌入路径相对包目录,调试时按工作目录拼接
files[i] = filepath.Join("internal", "web", f)
}
t, err := template.New(filepath.Base(files[0])).Funcs(templateFuncs()).ParseFiles(files...)
if err != nil {
return pageSet{}, err
}
return pageSet{t: t, entry: def.entry}, nil
}
func (s *Server) json(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("JSON 输出失败: %v", err)
}
}
// serverError 统一处理内部错误。详情只回给已登录的站长:公开访客页把 err.Error()
// 原样回显,等于把 SQL 错误里的表结构、列名、以及磁盘路径直接送到门外。
func (s *Server) serverError(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("处理 %s 出错: %v", r.URL.Path, err)
msg := "服务器内部错误,请稍后重试"
if userFrom(r.Context()) != nil {
msg = "服务器内部错误: " + err.Error()
}
http.Error(w, msg, http.StatusInternalServerError)
}
func atoiOr(s string, def int) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return def
}
return n
}
func parseInt64s(vals []string) []int64 {
var out []int64
for _, v := range vals {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
out = append(out, n)
}
}
return out
}
// datasetFor 取当前生效的数据集;无数据时返回 nil。
func (s *Server) datasetFor(ctx context.Context) (*Dataset, error) {
var d Dataset
err := s.db.QueryRowContext(ctx, `SELECT id, original_name, uploaded_at, visit_count, place_count,
raw_visit_count, first_visit_at, last_visit_at, size_bytes
FROM datasets WHERE is_active AND status='done' ORDER BY uploaded_at DESC LIMIT 1`).
Scan(&d.ID, &d.Name, &d.UploadedAt, &d.Visits, &d.Places, &d.RawVisits, &d.First, &d.Last, &d.Size)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &d, nil
}
type Dataset struct {
ID int64
Name string
UploadedAt time.Time
Visits int
Places int
RawVisits int
First *time.Time
Last *time.Time
Size int64
}