package web import ( "bytes" "fmt" "strings" "rond-with-you/internal/setting" ) // 启动时的模板自检:把每个页面各用一份「最小可用数据」渲染一遍,任一页失败就拒绝启动。 // // 防的是一类**只在执行期暴露**的错误:模板引用了 Go 结构体里不存在的字段 // (典型是改了结构体却没重新编译,磁盘上的新模板被老二进制拿去渲染), // 或者模板里对着 nil 解引用。这类错误既不会让模板解析失败,render 又只把错误写进日志, // 于是线上表现成「页面渲染到一半就断了、HTTP 却仍是 200」——用户对着半张没有收尾的页面 // 猜半天,服务端日志里只有孤零零一行。 // // 2026-10-09 就是这么踩的:提交里同时改了 setting.Settings 和 settings.html, // 但跑的是改动前编译的二进制,debug 模式下模板走磁盘热加载 ⇒ 新模板 + 旧结构体, // /admin/settings 在「统计脚本」那张卡片处被截断。启动自检能把这种组合直接挡在门外。 // // 探针值只求「类型对、不炸」:这些页面本来就得能渲染空数据(还没导入数据时的首页就是空的), // 所以空值探针是合理的下限,而不是凑数。 var pageProbe = map[string]func() any{ "home": func() any { return HomeData{Page: probePage("home")} }, "map": func() any { return MapData{Page: probePage("map")} }, "places": func() any { return PlacesData{Page: probePage("places")} }, "place": func() any { return PlaceData{Page: probePage("places")} }, "timeline": func() any { return TimelineData{Page: probePage("timeline")} }, "stats": func() any { return StatsData{Page: probePage("stats")} }, "report": func() any { return ReportData{Page: probePage("report")} }, "topics": func() any { return TopicListData{Page: probePage("topics")} }, "topic": func() any { return TopicData{Page: probePage("topics")} }, "admin/index": func() any { return AdminData{Page: probePage("admin"), MaxSize: maxUploadBytes} }, "admin/password": func() any { return PasswordData{Page: probePage("admin"), Username: "admin"} }, "admin/twofa": func() any { return TwoFAData{Page: probePage("admin")} }, "admin/fog-mask": func() any { return FogMaskData{Page: probePage("admin")} }, "admin/fog-preview": func() any { return FogPreviewData{Page: probePage("admin")} }, "admin/backup-preview": func() any { return BackupDiffData{Page: probePage("admin")} }, "admin/baseline": func() any { return BaselineData{Page: probePage("admin"), MaxSize: maxUploadBytes} }, "admin/fillup": func() any { return FillupData{Page: probePage("admin")} }, "admin/notes": func() any { return NotesData{Page: probePage("admin")} }, "admin/edit": func() any { return EditData{Page: probePage("admin")} }, "admin/topics": func() any { return TopicAdminData{Page: probePage("admin")} }, "admin/photos": func() any { return PhotoData{Page: probePage("admin")} }, "admin/login": func() any { return LoginData{Settings: setting.Default()} }, "admin/twofa-login": func() any { return TwoFALoginData{Settings: setting.Default()} }, "admin/settings": func() any { return SettingsData{Page: probePage("admin"), Modules: setting.AllModules, Pages: setting.AllPages, MapLayers: []MapLayerOption{{Key: "k", Label: "l"}}} }, } // probePage 是所有探针共用的页面上下文:给足站点设置,免得模板在取值阶段就断。 func probePage(nav string) Page { return Page{Nav: nav, Title: "模板自检", Settings: setting.Default()} } // smokeRender 把所有页面各渲染一遍,专为触发执行期错误。 // 任一页失败都返回错误,让 New 拒绝启动——宁可起不来,也不要起来后一半页面是残的。 func (s *Server) smokeRender() error { var failed []string for key := range pageFiles { probe, ok := pageProbe[key] if !ok { // 没有探针的页面等于没被自检覆盖。这是漏,不是省事,所以按失败处理。 failed = append(failed, key+": pageProbe 里没有配自检数据") continue } if err := s.smokeOne(key, probe()); err != nil { failed = append(failed, err.Error()) } } if len(failed) > 0 { return fmt.Errorf("模板自检未通过,多半是 Go 结构体与模板字段不同步(改完 Go 要重新编译):\n %s", strings.Join(failed, "\n ")) } return nil } func (s *Server) smokeOne(key string, data any) (err error) { // 模板里调到的方法可能对着空数据 panic,别让它把整个启动带走—— // 这里要的是一个可读的「哪一页坏了」,不是一段崩栈。 defer func() { if r := recover(); r != nil { err = fmt.Errorf("%s: 渲染时 panic: %v", key, r) } }() set, err := s.pageSet(key) if err != nil { return fmt.Errorf("%s: %w", key, err) } var buf bytes.Buffer if err := set.t.ExecuteTemplate(&buf, set.entry, data); err != nil { return fmt.Errorf("%s: %w", key, err) } return nil }