提问背景 Question:
下面是,baike完整的go程序,里面有渲染的方式和相关可以参考的设计方式。模板文件名就定为sitemap.html吧。访问路由也就/baike/sitemap这样了。
package ai
import (
"bytes"
"database/sql"
"html/template"
"net/http"
"path/filepath"
"strconv"
"strings"
"stv/fun"
"time"
"github.com/julienschmidt/httprouter"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/renderer/html"
)
// 定义全局单例实例
var md goldmark.Markdown
func init() {
// 在 init 函数中一次性完成初始化,避免每次调用时重复创建
md = goldmark.New(
goldmark.WithRendererOptions(
// html.WithHardWraps() 对应渲染器中的硬换行配置(将渲染为 <br />)
html.WithHardWraps(),
// 如果需要支持 HTML 标签(默认是转义的),可以加上这一行:
// html.WithUnsafe(),
),
)
}
// Article 对应 cms_article 表及关联标签
type Article struct {
ID int
Question string
Title string
Description template.HTML
Content template.HTML // 使用 template.HTML 避免 HTML 标签被转义
ClickCount int
LikeCount int
CreatedAt string
Tags []Tag
}
// Tag 对应 cms_tag 标签表
type Tag struct {
ID int
Name string
Count int
}
// BaikeIndexData 首页模板渲染数据
type BaikeIndexData struct {
Articles []Article
HotTags []Tag
Keyword string
}
// BaikeDetailData 详情页模板渲染数据
type BaikeDetailData struct {
Article Article
Tags []Tag
}
// 1. 百科首页 Handler(带搜索支持)
// 路由配置:r.GET("/baike", ai.Baike_index)
func Baike_index(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
query := r.URL.Query()
keyword := query.Get("q")
tagName := query.Get("tag")
// 获取热门标签 (前20个)
var hotTags []Tag
rows, err := fun.DB.Query("SELECT id, name, count FROM cms_tag WHERE count > 0 ORDER BY count DESC LIMIT 20")
if err == nil {
for rows.Next() {
var t Tag
if err := rows.Scan(&t.ID, &t.Name, &t.Count); err == nil {
hotTags = append(hotTags, t)
}
}
rows.Close()
}
// 查询文章列表
var articles []Article
var sqlStr string
var args []interface{}
if keyword != "" {
// 关键词搜索模式
sqlStr = `SELECT id, title, description, click_count, created_at
FROM cms_article
WHERE status = 1 AND (title LIKE ? OR content LIKE ? OR question LIKE ?)
ORDER BY id DESC LIMIT 50`
likeKey := "%" + keyword + "%"
args = append(args, likeKey, likeKey, likeKey)
} else if tagName != "" {
// 标签过滤模式
sqlStr = `SELECT a.id, a.title, a.description, a.click_count, a.created_at
FROM cms_article a
INNER JOIN cms_article_tag at ON a.id = at.article_id
INNER JOIN cms_tag t ON t.id = at.tag_id
WHERE a.status = 1 AND t.name = ?
ORDER BY a.id DESC LIMIT 50`
args = append(args, tagName)
} else {
// 默认最新列表
sqlStr = `SELECT id, title, description, click_count, created_at
FROM cms_article
WHERE status = 1 and score>80
ORDER BY id DESC LIMIT 15`
}
rows, err = fun.DB.Query(sqlStr, args...)
if err != nil {
http.Error(w, "读取数据库失败: "+err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var a Article
var createdAtInt int64
err := rows.Scan(&a.ID, &a.Title, &a.Description, &a.ClickCount, &createdAtInt)
if err == nil {
// 格式化时间戳
a.CreatedAt = time.Unix(createdAtInt, 0).Format("2006-01-02 15:04")
articles = append(articles, a)
}
}
// 拼装首页数据
data := BaikeIndexData{
Articles: articles,
HotTags: hotTags,
Keyword: keyword,
}
// 渲染模板
path := filepath.Join(fun.AppPath, "/templates/ai/baike_index.html")
mb, err := fun.TManager.Get(path)
if err != nil {
http.Error(w, "模板加载失败: "+err.Error(), http.StatusInternalServerError)
return
}
err = mb.Execute(w, data)
if err != nil {
fun.WriteLog("渲染百科首页模板报错: %s", err)
}
}
// 2. 百科详情页 Handler
// 路由配置:r.GET("/baike/article/:id", ai.Baike_detail)
func Baike_detail(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
articleIDStr := ps.ByName("id")
articleID, err := strconv.Atoi(articleIDStr)
if err != nil {
http.NotFound(w, r)
return
}
// 1. 查询文章主体
var a Article
var descriptionRaw string // 用临时变量接收数据库中的原始 String
var contentRaw string
var createdAtInt int64
row := fun.DB.QueryRow(
"SELECT id, question, title, description, content, click_count, like_count, created_at FROM cms_article WHERE id = ? AND status = 1",
articleID,
)
err = row.Scan(&a.ID, &a.Question, &a.Title, &descriptionRaw, &contentRaw, &a.ClickCount, &a.LikeCount, &createdAtInt)
if err != nil {
if err == sql.ErrNoRows {
http.NotFound(w, r)
} else {
http.Error(w, "数据库错误: "+err.Error(), http.StatusInternalServerError)
}
return
}
a.Description = ConvertMarkdown(descriptionRaw) // 解析提问背景的 Markdown
a.Content = ConvertMarkdown(contentRaw) //解析Markdown并转义template.HTML
a.CreatedAt = time.Unix(createdAtInt, 0).Format("2006-01-02 15:04")
// 2. 异步增加浏览量(避免阻塞主渲染)
go func(id int) {
_, _ = fun.DB.Exec("UPDATE cms_article SET click_count = click_count + 1 WHERE id = ?", id)
}(articleID)
// 3. 查询当前文章的 Tags
var tags []Tag
tagRows, err := fun.DB.Query(`
SELECT t.id, t.name
FROM cms_tag t
INNER JOIN cms_article_tag at ON t.id = at.tag_id
WHERE at.article_id = ?`,
articleID,
)
if err == nil {
for tagRows.Next() {
var t Tag
if err := tagRows.Scan(&t.ID, &t.Name); err == nil {
tags = append(tags, t)
}
}
tagRows.Close()
}
data := BaikeDetailData{
Article: a,
Tags: tags,
}
// 4. 渲染模板
path := filepath.Join(fun.AppPath, "/templates/ai/baike_detail.html")
mb, err := fun.TManager.Get(path)
if err != nil {
http.Error(w, "详情模板渲染失败: "+err.Error(), http.StatusInternalServerError)
return
}
err = mb.Execute(w, data)
if err != nil {
fun.WriteLog("渲染百科详情页模板报错: %s", err)
}
}
func ConvertMarkdown(mdContent string) template.HTML {
// 1. 将字面量的 "" (反斜杠+n) 替换为真正的换行符 ""
mdContent = strings.ReplaceAll(mdContent, "\\n", "\n")
// 2. 兼容性处理:将字面量的 "\\r" 替换为真正的 "\r"
mdContent = strings.ReplaceAll(mdContent, "\\r", "\n")
// 3. 兼容性处理:将 Windows 换行符统一替换为 Linux 换行符(可选,防患未然)
mdContent = strings.ReplaceAll(mdContent, "\r", "\n")
var buf bytes.Buffer
if err := md.Convert([]byte(mdContent), &buf); err != nil {
return template.HTML(mdContent) // 如果解析失败,回退返回原始文本
}
return template.HTML(buf.String())
}
Go 模板网站地图实现方案与完整代码
为了完美契合您之前设计的网站地图模板 (sitemap.html),并保证其在生产环境下的极高加载性能和 SEO 蜘蛛爬行效率,我们需要在 baike.go 中建立按标签(Tag)作为虚拟分类的数据分组聚合逻辑。
在搜索引擎蜘蛛(如 Googlebot、Baiduspider)的视角中,将文章按「标签目录」分类归档,并限制单页渲染的链接数(防止因单页超链接过多而被搜索引擎惩罚),是最标准的做法。
1. 新增的数据结构定义
请在您的 baike.go 文件中的 BaikeDetailData 下方添加以下供 sitemap.html 渲染使用的数据结构:
// CategoryGroup 对应 sitemap 模板中的分类节点
type CategoryGroup struct {
CategoryName string
Articles []Article
}
// BaikeSitemapData 对应 sitemap 模板的渲染数据
type BaikeSitemapData struct {
CategoryGroups []CategoryGroup
HasPagination bool
CurrentPage int
TotalPages int
PageNums []int
}
2. 编写 Baike_sitemap 处理函数
在 baike.go 的末尾添加该处理函数。此函数支持流式分页,每页默认展示 10 个标签分类,每个分类下聚合展示最新及权重较高的 30 篇百科文章:
// 3. 百科网站地图/目录树 Handler
// 路由配置:r.GET("/baike/sitemap", ai.Baike_sitemap)
func Baike_sitemap(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
query := r.URL.Query()
pageStr := query.Get("page")
currentPage := 1
if pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
currentPage = p
}
}
// 定义分页配置:每页显示 10 个分类标签
pageSize := 10
offset := (currentPage - 1) * pageSize
// 1. 查询有文章的标签总数(用于分页计算)
var totalTags int
err := fun.DB.QueryRow("SELECT COUNT(1) FROM cms_tag WHERE count > 0").Scan(&totalTags)
if err != nil {
http.Error(w, "计算标签失败: "+err.Error(), http.StatusInternalServerError)
return
}
// 计算总页数
totalPages := (totalTags + pageSize - 1) / pageSize
if totalPages < 1 {
totalPages = 1
}
if currentPage > totalPages {
currentPage = totalPages
offset = (currentPage - 1) * pageSize
}
// 2. 分页获取热门/非空标签列表
tagRows, err := fun.DB.Query("SELECT id, name, count FROM cms_tag WHERE count > 0 ORDER BY count DESC, id DESC LIMIT ? OFFSET ?", pageSize, offset)
if err != nil {
http.Error(w, "查询标签失败: "+err.Error(), http.StatusInternalServerError)
return
}
defer tagRows.Close()
var groups []CategoryGroup
// 3. 循环获取每个标签分类下的高权重公开文章 (每组显示最新 30 篇)
for tagRows.Next() {
var t Tag
if err := tagRows.Scan(&t.ID, &t.Name, &t.Count); err == nil {
var articles []Article
// 查询关联文章列表,必须满足 status = 1 正常状态
articleRows, err := fun.DB.Query(`
SELECT a.id, a.title
FROM cms_article a
INNER JOIN cms_article_tag at ON a.id = at.article_id
WHERE a.status = 1 AND at.tag_id = ?
ORDER BY a.id DESC LIMIT 30`,
t.ID,
)
if err == nil {
for articleRows.Next() {
var a Article
if err := articleRows.Scan(&a.ID, &a.Title); err == nil {
articles = append(articles, a)
}
}
articleRows.Close()
}
// 仅当分类下有文章才展示到目录树中
if len(articles) > 0 {
groups = append(groups, CategoryGroup{
CategoryName: t.Name,
Articles: articles,
})
}
}
}
// 生成分页页码数组
var pageNums []int
for i := 1; i <= totalPages; i++ {
pageNums = append(pageNums, i)
}
data := BaikeSitemapData{
CategoryGroups: groups,
HasPagination: totalPages > 1,
CurrentPage: currentPage,
TotalPages: totalPages,
PageNums: pageNums,
}
// 4. 读取模板文件并渲染
path := filepath.Join(fun.AppPath, "/templates/ai/sitemap.html")
mb, err := fun.TManager.Get(path)
if err != nil {
http.Error(w, "加载地图模板失败: "+err.Error(), http.StatusInternalServerError)
return
}
err = mb.Execute(w, data)
if err != nil {
fun.WriteLog("渲染百科地图页模板报错: %s", err)
}
}
3. 需要同步修改的页面模板细节 (sitemap.html 分页部分)
您之前的模板中分页使用了固定的静态 A 标签链接。为了让它随 Go 后端数据动态输出,请将 sitemap.html 中的分页区修改为以下标准的 Go 循环渲染逻辑:
<!-- 分页区域:完美动态生成 -->
{{if .HasPagination}}
<div class="pagination">
{{$curr := .CurrentPage}}
{{range .PageNums}}
<a href="?page={{.}}" class="page-btn{{if eq . $curr}} active{{end}}">{{.}}</a>
{{end}}
</div>
{{end}}
4. 路由配置指引
在您项目注册 HTTP 路由的地方(通常在 main.go 或路由配置文件中),添加对应的 GET 路由指向新创建的方法即可:
// 注册百科全站 HTML 目录树(网站地图)
router.GET("/baike/sitemap", ai.Baike_sitemap)
为什么这个设计对搜索引擎极具吸引力?
- 极佳的语义层级:使用标准的
nav>h2>ul>li>a语义树,搜索引擎蜘蛛解析起来毫无障碍。 - 物理分页控流:控制单页分类数与文章显示上限,避免页面体积过大(控制在 100KB 以内),有利于蜘蛛的高频轻量抓取。
- 标签关联聚合:打破了传统单一死板的物理分类,让高关联的文章多入口曝光,显著缩短了蜘蛛发现新文章、未收录文章的物理路径。