feat: 可配置站点 URL 风格

- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
This commit is contained in:
2026-09-27 04:01:42 +08:00
parent 1d862a0dd3
commit 7a44c1f2cf
64 changed files with 1183 additions and 200 deletions

View File

@@ -245,8 +245,12 @@ func (h *Handlers) SiteState(c *gin.Context) {
c.JSON(503, gin.H{"error": "状态暂不可用"})
return
}
urlStyle, e := h.Setting.UrlStyle()
if e != nil {
urlStyle = service.DefaultUrlStyle
}
c.Header("Cache-Control", "no-store")
c.JSON(200, gin.H{"allow_register": cfg.AllowRegister, "register_notice": cfg.RegisterNotice, "verify_email": cfg.VerifyEmail, "password_reset": cfg.PasswordReset, "maintenance": m, "bypass": h.administrator(c), "site_url": h.Cfg.SiteURL})
c.JSON(200, gin.H{"allow_register": cfg.AllowRegister, "register_notice": cfg.RegisterNotice, "verify_email": cfg.VerifyEmail, "password_reset": cfg.PasswordReset, "maintenance": m, "bypass": h.administrator(c), "site_url": h.Cfg.SiteURL, "url_style": urlStyle})
}
func (h *Handlers) SendEmailCode(c *gin.Context) {
var req struct {

View File

@@ -75,6 +75,8 @@ type updateSettingsRequest struct {
Levels *[]model.LevelDef `json:"levels"`
LevelsFx *bool `json:"levels_fx"`
UrlStyle *string `json:"url_style"`
}
func settingsPayload(saved service.PublicSiteSettings) gin.H {
@@ -132,6 +134,8 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"levels": saved.Levels,
"levels_fx": saved.LevelsFx,
"url_style": saved.UrlStyle,
}
}
@@ -154,7 +158,8 @@ func (req *updateSettingsRequest) hasAny() bool {
req.PointsReplyReward != nil || req.PointsReplyDailyCap != nil ||
req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil ||
req.PostEditLockHours != nil || req.CommentEditLockHours != nil ||
req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil
req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil ||
req.UrlStyle != nil
}
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
@@ -537,6 +542,16 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
return
}
}
if req.UrlStyle != nil {
if err := h.Setting.SetUrlStyle(*req.UrlStyle); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "链接风格无效"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
saved, err := h.Setting.Public()
if err != nil {

View File

@@ -80,6 +80,9 @@ const (
// SettingKeyLevelsFx 等级特效动画总开关(流光/渐变动画);缺行视为开启(默认开)
SettingKeyLevelsFx = "levels_fx"
// SettingKeyUrlStyle 详情页伪静态风格;缺行视为 default(标准动态路由风格)
SettingKeyUrlStyle = "url_style"
)
const (
@@ -112,6 +115,9 @@ const (
DefaultPointsCheckinBase = 5
DefaultPointsStreakEveryDays = 7
MaxPointsRule = 100 // 各积分规则值/上限的统一上界
// DefaultUrlStyle 标准动态路由风格(/post/123)
DefaultUrlStyle = "default"
)
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
@@ -127,6 +133,9 @@ var DefaultAttachmentExts = []string{
var attachmentExtRe = regexp.MustCompile(`^[a-z0-9]{1,16}$`)
// UrlStyles 伪静态风格白名单,与前端 lib/urlStyle.ts 保持一致
var UrlStyles = []string{"default", "html", "discuz"}
// ErrInvalidAccent 主题色格式非法
var ErrInvalidAccent = errors.New("主题色格式不正确,应为 #RRGGBB")
@@ -194,6 +203,9 @@ type PublicSiteSettings struct {
Levels []model.LevelDef `json:"levels"`
// LevelsFx 等级特效动画总开关(流光/渐变动画;关闭后徽章退化为静态配色)
LevelsFx bool `json:"levels_fx"`
// UrlStyle 详情页伪静态风格:default(/post/123)、html(/post-123.html)、discuz(/thread-123.html)
UrlStyle string `json:"url_style"`
}
// SettingService 站点级键值设置
@@ -297,6 +309,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
BrandLogoSize: BrandLogoSizeSQ,
BrandLogoFit: BrandLogoFitContain,
FooterLinks: []FooterLink{},
UrlStyle: DefaultUrlStyle,
}
accent, err := s.AccentColor()
if err != nil {
@@ -494,12 +507,50 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
return out, err
}
out.BgAdminMode = adminMode
urlStyle, err := s.UrlStyle()
if err != nil {
return out, err
}
out.UrlStyle = urlStyle
if err := s.fillBrand(&out); err != nil {
return out, err
}
return out, nil
}
// UrlStyle 返回详情页伪静态风格;缺行或值非法时回落 default
func (s *SettingService) UrlStyle() (string, error) {
v, found, err := s.getValue(SettingKeyUrlStyle)
if err != nil {
return DefaultUrlStyle, err
}
if !found {
return DefaultUrlStyle, nil
}
style := strings.TrimSpace(strings.ToLower(v))
for _, valid := range UrlStyles {
if style == valid {
return style, nil
}
}
return DefaultUrlStyle, nil
}
// SetUrlStyle 校验并保存伪静态风格;传 default 表示恢复标准风格(删除该键)
func (s *SettingService) SetUrlStyle(style string) error {
style = strings.TrimSpace(strings.ToLower(style))
if style == DefaultUrlStyle {
return s.deleteKey(SettingKeyUrlStyle)
}
for _, valid := range UrlStyles {
if style == valid {
return s.putValue(SettingKeyUrlStyle, style)
}
}
return ErrInvalidSiteSetting
}
// AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色
func (s *SettingService) AccentColor() (string, error) {
v, found, err := s.getValue(SettingKeyAccent)

View File

@@ -0,0 +1,112 @@
package service
import (
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newUrlStyleTestService(t *testing.T) *SettingService {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.SiteSetting{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return NewSettingService(db)
}
func TestUrlStyleDefaultWhenMissing(t *testing.T) {
s := newUrlStyleTestService(t)
got, err := s.UrlStyle()
if err != nil {
t.Fatal(err)
}
if got != DefaultUrlStyle {
t.Fatalf("missing row should fall back to %q, got %q", DefaultUrlStyle, got)
}
pub, err := s.Public()
if err != nil {
t.Fatal(err)
}
if pub.UrlStyle != DefaultUrlStyle {
t.Fatalf("Public().UrlStyle = %q, want %q", pub.UrlStyle, DefaultUrlStyle)
}
}
func TestSetUrlStyleRoundTrip(t *testing.T) {
s := newUrlStyleTestService(t)
for _, style := range []string{"html", "discuz"} {
if err := s.SetUrlStyle(style); err != nil {
t.Fatalf("SetUrlStyle(%q): %v", style, err)
}
got, err := s.UrlStyle()
if err != nil {
t.Fatal(err)
}
if got != style {
t.Fatalf("UrlStyle() = %q, want %q", got, style)
}
pub, err := s.Public()
if err != nil {
t.Fatal(err)
}
if pub.UrlStyle != style {
t.Fatalf("Public().UrlStyle = %q, want %q", pub.UrlStyle, style)
}
}
}
func TestSetUrlStyleDefaultDeletesKey(t *testing.T) {
s := newUrlStyleTestService(t)
if err := s.SetUrlStyle("html"); err != nil {
t.Fatal(err)
}
// 传 default 应删键,恢复「缺行=默认」语义(大小写与空白容错)
if err := s.SetUrlStyle(" DEFAULT "); err != nil {
t.Fatal(err)
}
got, err := s.UrlStyle()
if err != nil {
t.Fatal(err)
}
if got != DefaultUrlStyle {
t.Fatalf("after reset got %q, want %q", got, DefaultUrlStyle)
}
var rows int64
s.db.Where("key = ?", SettingKeyUrlStyle).Model(&model.SiteSetting{}).Count(&rows)
if rows != 0 {
t.Fatalf("default should delete the row, rows=%d", rows)
}
}
func TestSetUrlStyleRejectsInvalid(t *testing.T) {
s := newUrlStyleTestService(t)
for _, bad := range []string{"", "pretty", "HTML5", "thread", "../etc"} {
if err := s.SetUrlStyle(bad); err == nil {
t.Fatalf("SetUrlStyle(%q) should fail", bad)
}
}
// 非法值不应落库
if _, err := s.UrlStyle(); err != nil {
t.Fatal(err)
}
}
func TestUrlStyleInvalidStoredValueFallsBack(t *testing.T) {
s := newUrlStyleTestService(t)
if err := s.putValue(SettingKeyUrlStyle, "nonsense"); err != nil {
t.Fatal(err)
}
got, err := s.UrlStyle()
if err != nil {
t.Fatal(err)
}
if got != DefaultUrlStyle {
t.Fatalf("invalid stored value should fall back to %q, got %q", DefaultUrlStyle, got)
}
}