300 lines
9.6 KiB
Go
300 lines
9.6 KiB
Go
package controllers
|
||
|
||
import (
|
||
"fmt"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"server/models"
|
||
"server/pkg/tagengine"
|
||
|
||
"github.com/beego/beego/v2/client/orm"
|
||
beego "github.com/beego/beego/v2/server/web"
|
||
)
|
||
|
||
// TenantSiteController 租户官网前台渲染(Go 直出 HTML,无需登录)
|
||
// 路由:GET / | /news | /news/:id | /page/:path
|
||
type TenantSiteController struct {
|
||
beego.Controller
|
||
}
|
||
|
||
// siteNotFoundPage 未匹配域名的提示页
|
||
const siteNotFoundPage = `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head><meta charset="utf-8"><title>站点不存在</title>
|
||
<style>body{font-family:"Microsoft YaHei",sans-serif;background:#f5f6f8;color:#333;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||
.box{background:#fff;padding:48px 64px;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,.06);text-align:center}
|
||
h1{font-size:22px;margin:0 0 12px}p{color:#888;font-size:14px;margin:0}</style></head>
|
||
<body><div class="box"><h1>站点不存在</h1><p>当前域名未绑定任何租户官网,请检查域名配置。</p></div></body>
|
||
</html>`
|
||
|
||
// renderErrorPage 渲染失败的兜底页
|
||
const renderErrorPage = `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head><meta charset="utf-8"><title>页面异常</title>
|
||
<style>body{font-family:"Microsoft YaHei",sans-serif;background:#f5f6f8;color:#333;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||
.box{background:#fff;padding:48px 64px;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,.06);text-align:center}
|
||
h1{font-size:22px;margin:0 0 12px}p{color:#888;font-size:14px;margin:0}</style></head>
|
||
<body><div class="box"><h1>页面暂时无法访问</h1><p>%s</p></div></body>
|
||
</html>`
|
||
|
||
// resolveTid 按访问 Host 识别租户 ID。
|
||
// 规则:dev 环境支持 X-Tenant-ID 头切换 → localhost/127.0.0.1 默认租户 1
|
||
// → 平台主域名池命中返回租户 1(平台站点)→ 租户域名表命中返回对应 tid
|
||
// → 全部未命中返回 0,false(不再默认回落租户 1)
|
||
func (c *TenantSiteController) resolveTid() (uint64, bool) {
|
||
runmode, _ := beego.AppConfig.String("runmode")
|
||
if runmode == "dev" {
|
||
if h := strings.TrimSpace(c.Ctx.Request.Header.Get("X-Tenant-ID")); h != "" {
|
||
if decoded, err := url.QueryUnescape(h); err == nil {
|
||
h = decoded
|
||
}
|
||
if n, err := strconv.ParseUint(h, 10, 64); err == nil && n > 0 {
|
||
return n, true
|
||
}
|
||
var tenant models.SystemTenant
|
||
cond := orm.NewCondition().Or("tenant_name", h).Or("tenant_short_name", h)
|
||
if err := models.Orm.QueryTable(new(models.SystemTenant)).SetCond(cond).One(&tenant); err == nil {
|
||
return tenant.ID, true
|
||
}
|
||
}
|
||
}
|
||
|
||
host := c.Ctx.Request.Host
|
||
if idx := strings.Index(host, ":"); idx > 0 {
|
||
host = host[:idx]
|
||
}
|
||
if host == "localhost" || host == "127.0.0.1" {
|
||
return 1, true
|
||
}
|
||
|
||
// 平台主域名池命中 → 平台站点(tid 1 + 默认模板)
|
||
poolCnt, _ := models.Orm.QueryTable(new(models.SystemDomainPool)).
|
||
Filter("main_domain", host).
|
||
Filter("status", 1).
|
||
Filter("delete_time__isnull", true).
|
||
Count()
|
||
if poolCnt > 0 {
|
||
return 1, true
|
||
}
|
||
|
||
var td models.SystemTenantDomain
|
||
err := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||
Filter("full_domain", host).
|
||
Filter("status", 1).
|
||
Filter("delete_time__isnull", true).
|
||
One(&td)
|
||
if err == nil && td.Tid != nil && *td.Tid > 0 {
|
||
return *td.Tid, true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// resolveTemplateCode 解析租户选用模板编码,缺失/停用/目录不存在均回退 default
|
||
func (c *TenantSiteController) resolveTemplateCode(tid uint64) string {
|
||
code := strings.TrimSpace(cmsTenantTemplateCode(tid))
|
||
if code != "" && code != "default" {
|
||
cnt, _ := models.Orm.QueryTable(new(models.CmsFrontendTemplate)).
|
||
Filter("code", code).
|
||
Filter("status", 1).
|
||
Filter("delete_time__isnull", true).
|
||
Count()
|
||
if cnt == 0 {
|
||
code = ""
|
||
}
|
||
}
|
||
if code == "" {
|
||
code = "default"
|
||
}
|
||
if !cmsTemplateDirExists(code) {
|
||
code = "default"
|
||
}
|
||
return code
|
||
}
|
||
|
||
// writeHTML 直出 HTML
|
||
func (c *TenantSiteController) writeHTML(status int, html string) {
|
||
c.Ctx.Output.SetStatus(status)
|
||
c.Ctx.Output.Header("Content-Type", "text/html; charset=utf-8")
|
||
c.Ctx.WriteString(c.rewriteStorageURLs(html))
|
||
}
|
||
|
||
var legacyQiniuDomains = []string{
|
||
"7colud.yunzer.cn",
|
||
"7cloud.yunzer.cn",
|
||
}
|
||
|
||
// rewriteStorageURLs 将历史七牛域名替换为当前存储配置的访问域名。
|
||
// 这样已存入数据库的旧完整 URL 会跟随新的 CDN 或 /qiniu/ 反向代理入口,
|
||
// 不需要批量更新业务数据。
|
||
func (c *TenantSiteController) rewriteStorageURLs(html string) string {
|
||
cfg, err := models.GetStorageConfig()
|
||
if err != nil || strings.TrimSpace(cfg.QiniuDomain) == "" {
|
||
return html
|
||
}
|
||
return rewriteLegacyQiniuURLs(html, storagePublicBaseURL(cfg.QiniuDomain))
|
||
}
|
||
|
||
func storagePublicBaseURL(domain string) string {
|
||
base := strings.TrimRight(strings.TrimSpace(domain), "/")
|
||
if base == "" || strings.HasPrefix(base, "/") || strings.HasPrefix(base, "http://") || strings.HasPrefix(base, "https://") {
|
||
return base
|
||
}
|
||
return "https://" + base
|
||
}
|
||
|
||
func rewriteLegacyQiniuURLs(html, targetBase string) string {
|
||
if targetBase == "" {
|
||
return html
|
||
}
|
||
for _, domain := range legacyQiniuDomains {
|
||
for _, scheme := range []string{"http://", "https://"} {
|
||
html = strings.ReplaceAll(html, scheme+domain+"/", targetBase+"/")
|
||
}
|
||
}
|
||
return html
|
||
}
|
||
|
||
// rewriteThemeAssets 将模板内书写的相对资源路径(href/src/url() 等)
|
||
// 统一改写为 /themes/{code}/ 下的绝对路径,保证任意页面深度(/、/news、
|
||
// /news/:id、/page/:path)下静态资源都能命中 beego 的 /themes 静态映射。
|
||
// 第三方模板通常直接写相对路径(如 assets/css/xxx.css),不改写会 404。
|
||
// 注:Go regexp(RE2)不支持 \1/\2 反向引用,闭合引号用 ["'] 宽松匹配;
|
||
// 值部分 [^"']* 不含引号,正常 HTML 下首尾引号必然一致,行为等价。
|
||
var (
|
||
reThemeAssetAttr = regexp.MustCompile(`(?i)\b(href|src|poster|data-src|srcset)\s*=\s*(["'])([^"']*)["']`)
|
||
reThemeAssetURL = regexp.MustCompile(`(?i)url\(\s*(["']?)([^"')]+)["']?\s*\)`)
|
||
)
|
||
|
||
func rewriteThemeAssets(html, code string) string {
|
||
prefix := "/themes/" + code + "/"
|
||
fix := func(u string) string {
|
||
u = strings.TrimSpace(u)
|
||
if u == "" {
|
||
return u
|
||
}
|
||
lower := strings.ToLower(u)
|
||
// 绝对路径/协议链接/锚点等一律不动(含引擎输出的 /uploads、存储 CDN 地址)
|
||
if strings.HasPrefix(u, "/") || strings.HasPrefix(u, "#") || strings.HasPrefix(u, "?") ||
|
||
strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") ||
|
||
strings.HasPrefix(lower, "//") || strings.HasPrefix(lower, "data:") ||
|
||
strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "mailto:") ||
|
||
strings.HasPrefix(lower, "tel:") {
|
||
return u
|
||
}
|
||
u = strings.TrimPrefix(u, "./")
|
||
for strings.HasPrefix(u, "../") {
|
||
u = strings.TrimPrefix(u, "../")
|
||
}
|
||
return prefix + u
|
||
}
|
||
|
||
html = reThemeAssetAttr.ReplaceAllStringFunc(html, func(m string) string {
|
||
sub := reThemeAssetAttr.FindStringSubmatch(m)
|
||
attr, quote, val := sub[1], sub[2], sub[3]
|
||
if strings.EqualFold(attr, "srcset") {
|
||
parts := strings.Split(val, ",")
|
||
for i, p := range parts {
|
||
seg := strings.Fields(strings.TrimSpace(p))
|
||
if len(seg) > 0 {
|
||
seg[0] = fix(seg[0])
|
||
parts[i] = strings.Join(seg, " ")
|
||
}
|
||
}
|
||
val = strings.Join(parts, ", ")
|
||
} else {
|
||
val = fix(val)
|
||
}
|
||
return attr + "=" + quote + val + quote
|
||
})
|
||
|
||
// 行内 style / <style> 块里的 url() 引用
|
||
html = reThemeAssetURL.ReplaceAllStringFunc(html, func(m string) string {
|
||
sub := reThemeAssetURL.FindStringSubmatch(m)
|
||
return "url(" + sub[1] + fix(sub[2]) + sub[1] + ")"
|
||
})
|
||
return html
|
||
}
|
||
|
||
// render 解析并输出模板文件
|
||
func (c *TenantSiteController) render(file string, mod func(ctx *tagengine.RenderCtx)) {
|
||
c.renderFile(file, "", mod)
|
||
}
|
||
|
||
// renderFile 解析并输出模板;preferred 不存在时尝试 fallback(fallback 为空则直接 404)
|
||
func (c *TenantSiteController) renderFile(preferred, fallback string, mod func(ctx *tagengine.RenderCtx)) {
|
||
tid, ok := c.resolveTid()
|
||
if !ok {
|
||
c.writeHTML(404, siteNotFoundPage)
|
||
return
|
||
}
|
||
|
||
code := c.resolveTemplateCode(tid)
|
||
dir := filepath.Join(cmsThemesRoot(), code)
|
||
file := preferred
|
||
if _, err := os.Stat(filepath.Join(dir, file)); err != nil {
|
||
if fallback != "" {
|
||
file = fallback
|
||
}
|
||
}
|
||
if _, err := os.Stat(filepath.Join(dir, file)); err != nil {
|
||
c.writeHTML(404, fmt.Sprintf(renderErrorPage, "模板页面不存在"))
|
||
return
|
||
}
|
||
|
||
ctx := &tagengine.RenderCtx{Tid: tid, Page: 1, PageSize: 10}
|
||
if mod != nil {
|
||
mod(ctx)
|
||
}
|
||
out, err := tagengine.Render(dir, file, ctx)
|
||
if err != nil {
|
||
c.writeHTML(500, fmt.Sprintf(renderErrorPage, "模板渲染失败"))
|
||
return
|
||
}
|
||
c.writeHTML(200, rewriteThemeAssets(out, code))
|
||
}
|
||
|
||
// Index GET / 首页
|
||
func (c *TenantSiteController) Index() {
|
||
c.render("index.html", nil)
|
||
}
|
||
|
||
// News GET /news 新闻列表页(?page=N 分页)
|
||
func (c *TenantSiteController) News() {
|
||
page, _ := c.GetInt("page", 1)
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
c.render("news.html", func(ctx *tagengine.RenderCtx) {
|
||
ctx.Page = page
|
||
})
|
||
}
|
||
|
||
// NewsDetail GET /news/:id 文章详情页
|
||
func (c *TenantSiteController) NewsDetail() {
|
||
id, _ := c.GetUint64(":id")
|
||
if id == 0 {
|
||
c.writeHTML(404, fmt.Sprintf(renderErrorPage, "文章不存在"))
|
||
return
|
||
}
|
||
c.render("news_detail.html", func(ctx *tagengine.RenderCtx) {
|
||
ctx.ArticleID = id
|
||
})
|
||
}
|
||
|
||
// Page GET /page/:path 单页
|
||
func (c *TenantSiteController) Page() {
|
||
path := strings.TrimSpace(c.GetString(":path"))
|
||
if path == "" || strings.ContainsAny(path, "/\\.") {
|
||
c.writeHTML(404, fmt.Sprintf(renderErrorPage, "页面不存在"))
|
||
return
|
||
}
|
||
c.renderFile(path+".html", "page.html", func(ctx *tagengine.RenderCtx) {
|
||
ctx.PagePath = path
|
||
})
|
||
}
|