Files
yunzerwebsiteallinone/go/controllers/tenant_site.go
T
2026-08-20 16:21:07 +08:00

300 lines
9.6 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.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
})
}