Files
yunzerwebsiteallinone/go/controllers/tenant_site.go
T
2026-08-20 10:30:53 +08:00

278 lines
9.0 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 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.forceHTTPSStorageURLs(html))
}
// forceHTTPSStorageURLs 访客以 HTTPS 访问站点时,把页面中存储(CDN)域名的
// http:// 资源升级为 https://,避免混合内容(Mixed Content)被浏览器拦截。
// 仅替换存储配置里那个域名,不动正文里的其他外部链接。
func (c *TenantSiteController) forceHTTPSStorageURLs(html string) string {
proto := c.Ctx.Input.Header("X-Forwarded-Proto")
if proto == "" && c.Ctx.Input.IsSecure() {
proto = "https"
}
if proto != "https" {
return html
}
cfg, err := models.GetStorageConfig()
if err != nil || strings.TrimSpace(cfg.QiniuDomain) == "" {
return html
}
host := strings.TrimSpace(cfg.QiniuDomain)
host = strings.TrimPrefix(host, "http://")
host = strings.TrimPrefix(host, "https://")
host = strings.TrimRight(host, "/")
if host == "" {
return html
}
return strings.ReplaceAll(html, "http://"+host, "https://"+host)
}
// 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)) {
tid, ok := c.resolveTid()
if !ok {
c.writeHTML(404, siteNotFoundPage)
return
}
code := c.resolveTemplateCode(tid)
dir := filepath.Join(cmsThemesRoot(), code)
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.render("page.html", func(ctx *tagengine.RenderCtx) {
ctx.PagePath = path
})
}