feat: 发评限流与品牌 Logo/Favicon/OG 上传

发评挂 RateLimiter comment;设置页支持品牌图上传/清除,并写入 favicon 与 og:image。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 04:06:48 +08:00
parent 4ffcaecba2
commit 118f967540
13 changed files with 255 additions and 58 deletions

View File

@@ -189,7 +189,8 @@
- [x] 敏感词:`forum_settings.filter_words` 读写 + 热更 — SSR `/admin/settings` - [x] 敏感词:`forum_settings.filter_words` 读写 + 热更 — SSR `/admin/settings`
- [x] 基础限流post/comment/register/login/window— SSR完整 Limits 字数等未迁 - [x] 基础限流post/comment/register/login/window— SSR完整 Limits 字数等未迁
- [x] SMTP 配置与测试信 — SSR `/admin/settings` 邮件区 - [x] SMTP 配置与测试信 — SSR `/admin/settings` 邮件区
- [x] 站点品牌文案名称、标语、简介、keywords、Logo 字标、ICP — SSRLogo/Favicon/OG 上传未迁) - [x] 站点品牌文案名称、标语、简介、keywords、Logo 字标、ICP — SSR `/admin/settings`
- [x] Logo / Favicon / 默认 OG 图上传与清除 — SSR `/admin/settings/brand/upload|clear``<head>` favicon + og:image
- [x] SQLite 一键备份与下载 — SSR `/admin/settings`(仅 sqlite写入 data 目录后下载) - [x] SQLite 一键备份与下载 — SSR `/admin/settings`(仅 sqlite写入 data 目录后下载)
--- ---
@@ -199,7 +200,7 @@
- [x] `GET /health``routers/setup.go` - [x] `GET /health``routers/setup.go`
- [x] `robots.txt` / `sitemap.xml` — SSR 同源路径 - [x] `robots.txt` / `sitemap.xml` — SSR 同源路径
- [x] 静态上传文件可达 — `Static /uploads` → data/uploads - [x] 静态上传文件可达 — `Static /uploads` → data/uploads
- [x] 限流:发帖、注册、登录、举报、私信、友链 — SSR 写路径已挂 `RateLimiter`评论动作键服务层有、web 发评尚未挂,可后补) - [x] 限流:发帖、评论、注册、登录、举报、私信、友链 — SSR 写路径已挂 `RateLimiter`
--- ---

View File

@@ -48,7 +48,7 @@
| `/admin/dashboard` | 概览计数 | 已迁 | | `/admin/dashboard` | 概览计数 | 已迁 |
| `/admin/boards` | 板块 CRUD | 已迁 | | `/admin/boards` | 板块 CRUD | 已迁 |
| `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 | | `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 |
| `/admin/settings` | 品牌 + 基础限流 + 敏感词 + SMTP | 已迁 | | `/admin/settings` | 品牌(含 Logo/Favicon/OG 上传)+ 基础限流 + 敏感词 + SMTP + 备份 | 已迁 |
| `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 | | `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 |
| `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 | | `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 |
| `/admin/reports` | 举报处理 | 已迁 | | `/admin/reports` | 举报处理 | 已迁 |
@@ -200,7 +200,7 @@
| Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` | | Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` |
| Media | 分类浏览;单删/批量删;同步索引 — SSR `/admin/media` | | Media | 分类浏览;单删/批量删;同步索引 — SSR `/admin/media` |
| Media | 分类浏览;批量删 | | Media | 分类浏览;批量删 |
| Settings | Tab论坛限制、侧栏组件、伪静态、邮件、OIDC+客户端、Gitea、存储、品牌、敏感词、备份 — SSR `/admin/settings` 已迁品牌/限流/侧栏/敏感词/邮件/SQLite 备份OIDC/Gitea/存储/伪静态未迁) | | Settings | Tab论坛限制、侧栏组件、伪静态、邮件、OIDC+客户端、Gitea、存储、品牌、敏感词、备份 — SSR `/admin/settings` 已迁品牌(含 Logo/Favicon/OG/限流/侧栏/敏感词/邮件/SQLite 备份OIDC/Gitea/存储/伪静态未迁) |
--- ---

View File

@@ -12,59 +12,36 @@
| 项 | 值 | | 项 | 值 |
|----|-----| |----|-----|
| **上一刀** | P2Admin 设等级 + 用户页徽章;勾选 Exp / 防刷 / 自动徽章 | | **上一刀** | P0发评限流 + 品牌 Logo/Favicon/OG 上传与 head meta |
| **下一刀** | **P3 体验**主题 / 伪静态 / 编辑器)或 **后置**OIDC / 存储热切换 / Gitea需点名 | | **下一刀** | **P3 / 后置**需点名:伪静态、完整 Limits、OIDC、S3 热切换、编辑器、嵌套评论等) |
| **工作区** | 应干净;有未提交改动时先处理再开新刀 | | **工作区** | 应干净 |
--- ---
## 一句话状态 ## 一句话状态
核心论坛 + Admin P1 + §H 成长/防刷(服务层 + Admin 设等级)**已齐**。余量P3 体验打磨与后置产品化OIDC / S3 热切换 / Gitea 核心论坛Admin P1§H 成长/防刷、P0 品牌图与发评限流 **已齐**。余量主要是体验打磨与后置产品化。
```mermaid
flowchart LR
core[核心论坛已迁]
admin[Admin用户徽章媒体]
growth[等级徽章防刷]
polish[主题伪静态编辑器]
deferred[OIDC存储Gitea]
core --> admin --> growth --> polish
deferred -.-> polish
```
--- ---
## 已完成里程碑 ## 已完成(摘)
### 公开站 / Admin / §H
| 域 | 状态 | | 域 | 状态 |
|----|------| |----|------|
| 五种帖类型、评论、私信、友链、单页、§O | 已迁 | | 五种帖 + Admin users/badges/media | 已迁 |
| Adminusers / badges / media / settings 基础 | 已迁 | | §H Exp/等级/徽章/防刷 | 已迁 |
| Exp→Lv、Admin 设等级、用户页徽章展示 | 已 | | 发评 `RateLimiter` comment | 已 |
| 短龄同 IP 互刷拒绝分成 | 已落地unlock 服务) | | 品牌 Logo/Favicon/OG 上传 + favicon/og meta | 已迁 |
| 自动徽章 EvaluateAuto | 已落地(访问用户页触发) |
### 近期提交(摘)
media → **等级设定 + 徽章展示 / §H 勾选**
--- ---
## 未完成分层 ## 未完成分层
### P0 — 小清理(可夹带) ### P0 余量
- 发评路径挂 `RateLimiter` `comment` 动作 - 头像裁剪等边角
- Logo/Favicon/OG 上传、头像裁剪
### P2成长与防刷 ### P3体验 / 编辑器
~~已完成主路径~~无独立「自动徽章定时任务」Admin UI可接受
### P3 — 体验 / 编辑器 ← 当前候选
- 主题、侧栏折叠、虚拟滚动、`feed_list_style`、伪静态 Admin - 主题、侧栏折叠、虚拟滚动、`feed_list_style`、伪静态 Admin
- TipTap / Markdown 双模 - TipTap / Markdown 双模
@@ -79,19 +56,9 @@ media → **等级设定 + 徽章展示 / §H 勾选**
--- ---
## 默认「继续」刀序 ## 默认「继续」
| 序 | 刀 | 产出 | 需点名下一刀;未点名时优先小清理或明确产品偏好。
|----|----|------|
| ✓ | 核心闭环 + Admin P1 | … |
| ✓ | §H 等级/防刷/徽章展示 | … |
| → | 需点名 P3 或后置 | … |
---
## 已完成计划
- [admin-users](plans/admin-users.md) · [admin-badges](plans/admin-badges.md) · [admin-media](plans/admin-media.md)
--- ---
@@ -99,7 +66,6 @@ media → **等级设定 + 徽章展示 / §H 勾选**
| 问题 | 看哪里 | | 问题 | 看哪里 |
|------|--------| |------|--------|
| 某功能做没做? | [02-features.md](02-features.md) 复选框 | | 某功能做没做? | [02-features.md](02-features.md) |
| 现在该干什么? | 本文「当前指针」 | | 现在该干什么? | 本文「当前指针」 |
| 路由迁没迁? | [06-pages-ux.md](06-pages-ux.md) §1 | | 路由迁没迁? | [06-pages-ux.md](06-pages-ux.md) §1 |
| 目录约定? | [08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md) |

View File

@@ -53,6 +53,12 @@ body.j13-body {
place-items: center; place-items: center;
font-weight: 700; font-weight: 700;
} }
.j13-brand__logo {
width: 2rem;
height: 2rem;
object-fit: contain;
border-radius: var(--j13-radius);
}
.j13-brand__text { display: flex; flex-direction: column; } .j13-brand__text { display: flex; flex-direction: column; }
.j13-brand__text strong { font-size: 1rem; } .j13-brand__text strong { font-size: 1rem; }
.j13-brand__slogan { font-size: 0.75rem; color: var(--j13-muted); } .j13-brand__slogan { font-size: 0.75rem; color: var(--j13-muted); }
@@ -1073,6 +1079,27 @@ points-only[data-locked="false"] {
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
} }
.j13-admin-brand-assets {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 1rem;
margin: 0 0 1.5rem;
}
.j13-admin-brand-asset {
border: 1px solid var(--j13-border);
border-radius: 6px;
padding: 0.75rem;
}
.j13-admin-brand-preview {
max-width: 8rem;
max-height: 4rem;
object-fit: contain;
display: block;
}
.j13-admin-brand-preview--sm {
max-width: 2rem;
max-height: 2rem;
}
.j13-admin-media-toolbar { .j13-admin-media-toolbar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -18,11 +18,19 @@ type Deps struct {
} }
type pageData struct { type pageData struct {
Title string Title string
Description string Description string
SiteName string SiteName string
Slogan string Slogan string
LogoMark string LogoMark string
LogoURL string
FaviconURL string
OGImageURL string
ShowFriendLinksFooter bool
FooterPages []struct {
Title string
Slug string
}
CSRF string CSRF string
Error string Error string
Flash string Flash string

View File

@@ -520,6 +520,90 @@ func (d Deps) AdminSettingsBrandPost(c *gin.Context) {
ctx.Redirect("/admin/settings") ctx.Redirect("/admin/settings")
} }
// AdminSettingsBrandUploadPost 上传 Logo / Favicon / OG 图
func (d Deps) AdminSettingsBrandUploadPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
if d.Store == nil {
d.renderAdminSettings(ctx, "上传存储未就绪")
return
}
kind := strings.TrimSpace(c.PostForm("kind"))
if kind != "logo" && kind != "favicon" && kind != "og_image" {
d.renderAdminSettings(ctx, "请选择 Logo、Favicon 或 OG 图")
return
}
file, err := c.FormFile("file")
if err != nil {
d.renderAdminSettings(ctx, "请选择图片文件")
return
}
const maxBytes = 2 * 1024 * 1024
if file.Size > maxBytes {
d.renderAdminSettings(ctx, "图片不能超过 2MB")
return
}
url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategorySite, kind)
if err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
prev := d.Settings.SiteBranding()
switch kind {
case "logo":
_ = d.Settings.SetSiteLogo(url)
d.Store.DeleteByURL(prev.Logo)
ctx.SetFlash("Logo 已上传")
case "favicon":
_ = d.Settings.SetSiteFavicon(url)
d.Store.DeleteByURL(prev.Favicon)
ctx.SetFlash("Favicon 已上传")
case "og_image":
_ = d.Settings.SetSiteOGImage(url)
d.Store.DeleteByURL(prev.OGImage)
ctx.SetFlash("默认 OG 图已上传")
}
ctx.Redirect("/admin/settings")
}
// AdminSettingsBrandClearPost 清除品牌图片
func (d Deps) AdminSettingsBrandClearPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
kind := strings.TrimSpace(c.PostForm("kind"))
brand := d.Settings.SiteBranding()
switch kind {
case "logo":
_ = d.Settings.SetSiteLogo("")
if d.Store != nil {
d.Store.DeleteByURL(brand.Logo)
}
ctx.SetFlash("已清除 Logo")
case "favicon":
_ = d.Settings.SetSiteFavicon("")
if d.Store != nil {
d.Store.DeleteByURL(brand.Favicon)
}
ctx.SetFlash("已清除 Favicon")
case "og_image":
_ = d.Settings.SetSiteOGImage("")
if d.Store != nil {
d.Store.DeleteByURL(brand.OGImage)
}
ctx.SetFlash("已清除 OG 图")
default:
d.renderAdminSettings(ctx, "无效的资源类型")
return
}
ctx.Redirect("/admin/settings")
}
// AdminSettingsLimitsPost 限流 // AdminSettingsLimitsPost 限流
func (d Deps) AdminSettingsLimitsPost(c *gin.Context) { func (d Deps) AdminSettingsLimitsPost(c *gin.Context) {
ctx := d.ctx(c) ctx := d.ctx(c)

View File

@@ -108,6 +108,9 @@ type PageChrome struct {
SiteName string SiteName string
Slogan string Slogan string
LogoMark string LogoMark string
LogoURL string
FaviconURL string
OGImageURL string
LoggedIn bool LoggedIn bool
IsAdmin bool IsAdmin bool
ViewerName string ViewerName string
@@ -170,6 +173,9 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome
SiteName: brand.Name, SiteName: brand.Name,
Slogan: brand.Slogan, Slogan: brand.Slogan,
LogoMark: firstRuneOr(brand.LogoMark, "姜"), LogoMark: firstRuneOr(brand.LogoMark, "姜"),
LogoURL: strings.TrimSpace(brand.Logo),
FaviconURL: strings.TrimSpace(brand.Favicon),
OGImageURL: strings.TrimSpace(brand.DefaultShareImage()),
LoggedIn: ctx.IsSigned(), LoggedIn: ctx.IsSigned(),
IsAdmin: ctx.IsAdmin(), IsAdmin: ctx.IsAdmin(),
ViewerName: name, ViewerName: name,

View File

@@ -75,6 +75,8 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
admin.POST("/comments/:id/reject", deps.AdminCommentReject) admin.POST("/comments/:id/reject", deps.AdminCommentReject)
admin.GET("/settings", deps.AdminSettingsGet) admin.GET("/settings", deps.AdminSettingsGet)
admin.POST("/settings/brand", deps.AdminSettingsBrandPost) admin.POST("/settings/brand", deps.AdminSettingsBrandPost)
admin.POST("/settings/brand/upload", deps.AdminSettingsBrandUploadPost)
admin.POST("/settings/brand/clear", deps.AdminSettingsBrandClearPost)
admin.POST("/settings/limits", deps.AdminSettingsLimitsPost) admin.POST("/settings/limits", deps.AdminSettingsLimitsPost)
admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost) admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost)
admin.POST("/settings/mail", deps.AdminSettingsMailPost) admin.POST("/settings/mail", deps.AdminSettingsMailPost)

View File

@@ -391,6 +391,11 @@ func (d Deps) PostComment(c *gin.Context) {
} }
} }
isPrivate := c.PostForm("is_private") == "1" || c.PostForm("is_private") == "on" isPrivate := c.PostForm("is_private") == "1" || c.PostForm("is_private") == "on"
if d.Limiter != nil && !d.Limiter.Allow("comment", fmt.Sprintf("%d", ctx.UserID())) {
ctx.SetFlash("操作过于频繁,请稍后再试")
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
return
}
safe := "<p>" + html.EscapeString(content) + "</p>" safe := "<p>" + html.EscapeString(content) + "</p>"
cm, err := d.Comment.Create(services.CommentCreateInput{ cm, err := d.Comment.Create(services.CommentCreateInput{
PostID: id, UserID: ctx.UserID(), Content: safe, PostID: id, UserID: ctx.UserID(), Content: safe,

View File

@@ -19,6 +19,69 @@
<button type="submit">保存品牌</button> <button type="submit">保存品牌</button>
</form> </form>
<h3>品牌图片</h3>
<p class="j13-muted">Logo / Favicon / 默认 Open Graph 图;单文件不超过 2MB。</p>
<div class="j13-admin-brand-assets">
<div class="j13-admin-brand-asset">
<strong>Logo</strong>
{{if .Brand.Logo}}
<p><img class="j13-admin-brand-preview" src="{{.Brand.Logo}}" alt="Logo"/></p>
<p class="j13-muted"><code>{{.Brand.Logo}}</code></p>
<form method="post" action="/admin/settings/brand/clear" class="j13-inline-form" onsubmit="return confirm('清除 Logo');">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<input type="hidden" name="kind" value="logo"/>
<button type="submit" class="j13-linkbtn">清除</button>
</form>
{{else}}
<p class="j13-muted">未设置(导航使用字标)</p>
{{end}}
<form method="post" action="/admin/settings/brand/upload" enctype="multipart/form-data" class="j13-form j13-admin-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<input type="hidden" name="kind" value="logo"/>
<label>上传 <input type="file" name="file" accept="image/*" required/></label>
<button type="submit">上传 Logo</button>
</form>
</div>
<div class="j13-admin-brand-asset">
<strong>Favicon</strong>
{{if .Brand.Favicon}}
<p><img class="j13-admin-brand-preview j13-admin-brand-preview--sm" src="{{.Brand.Favicon}}" alt="Favicon"/></p>
<form method="post" action="/admin/settings/brand/clear" class="j13-inline-form" onsubmit="return confirm('清除 Favicon');">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<input type="hidden" name="kind" value="favicon"/>
<button type="submit" class="j13-linkbtn">清除</button>
</form>
{{else}}
<p class="j13-muted">未设置</p>
{{end}}
<form method="post" action="/admin/settings/brand/upload" enctype="multipart/form-data" class="j13-form j13-admin-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<input type="hidden" name="kind" value="favicon"/>
<label>上传 <input type="file" name="file" accept="image/*" required/></label>
<button type="submit">上传 Favicon</button>
</form>
</div>
<div class="j13-admin-brand-asset">
<strong>默认 OG 图</strong>
{{if .Brand.OGImage}}
<p><img class="j13-admin-brand-preview" src="{{.Brand.OGImage}}" alt="OG"/></p>
<form method="post" action="/admin/settings/brand/clear" class="j13-inline-form" onsubmit="return confirm('清除 OG 图?');">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<input type="hidden" name="kind" value="og_image"/>
<button type="submit" class="j13-linkbtn">清除</button>
</form>
{{else}}
<p class="j13-muted">未设置(回落 Logo / Favicon</p>
{{end}}
<form method="post" action="/admin/settings/brand/upload" enctype="multipart/form-data" class="j13-form j13-admin-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<input type="hidden" name="kind" value="og_image"/>
<label>上传 <input type="file" name="file" accept="image/*" required/></label>
<button type="submit">上传 OG 图</button>
</form>
</div>
</div>
<h2>基础限流</h2> <h2>基础限流</h2>
<p class="j13-muted">窗口内允许的操作次数(秒)。</p> <p class="j13-muted">窗口内允许的操作次数(秒)。</p>
<form method="post" action="/admin/settings/limits" class="j13-form j13-admin-form"> <form method="post" action="/admin/settings/limits" class="j13-form j13-admin-form">

View File

@@ -6,6 +6,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{{.Title}}</title> <title>{{.Title}}</title>
{{if .Description}}<meta name="description" content="{{.Description}}"/>{{end}} {{if .Description}}<meta name="description" content="{{.Description}}"/>{{end}}
{{if .FaviconURL}}<link rel="icon" href="{{.FaviconURL}}"/>{{end}}
<meta property="og:title" content="{{.Title}}"/>
{{if .Description}}<meta property="og:description" content="{{.Description}}"/>{{end}}
{{if .OGImageURL}}<meta property="og:image" content="{{.OGImageURL}}"/>{{end}}
<link rel="stylesheet" href="/ssr-assets/site.css"/> <link rel="stylesheet" href="/ssr-assets/site.css"/>
</head> </head>
<body class="j13-body"> <body class="j13-body">

View File

@@ -2,7 +2,11 @@
<header class="j13-header"> <header class="j13-header">
<div class="j13-header__inner"> <div class="j13-header__inner">
<a class="j13-brand" href="/"> <a class="j13-brand" href="/">
{{if .LogoURL}}
<img class="j13-brand__logo" src="{{.LogoURL}}" alt="{{.SiteName}}"/>
{{else}}
<span class="j13-brand__mark">{{.LogoMark}}</span> <span class="j13-brand__mark">{{.LogoMark}}</span>
{{end}}
<span class="j13-brand__text"> <span class="j13-brand__text">
<strong>{{.SiteName}}</strong> <strong>{{.SiteName}}</strong>
{{if .Slogan}}<span class="j13-brand__slogan">{{.Slogan}}</span>{{end}} {{if .Slogan}}<span class="j13-brand__slogan">{{.Slogan}}</span>{{end}}

View File

@@ -53,6 +53,12 @@ body.j13-body {
place-items: center; place-items: center;
font-weight: 700; font-weight: 700;
} }
.j13-brand__logo {
width: 2rem;
height: 2rem;
object-fit: contain;
border-radius: var(--j13-radius);
}
.j13-brand__text { display: flex; flex-direction: column; } .j13-brand__text { display: flex; flex-direction: column; }
.j13-brand__text strong { font-size: 1rem; } .j13-brand__text strong { font-size: 1rem; }
.j13-brand__slogan { font-size: 0.75rem; color: var(--j13-muted); } .j13-brand__slogan { font-size: 0.75rem; color: var(--j13-muted); }
@@ -1073,6 +1079,27 @@ points-only[data-locked="false"] {
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
} }
.j13-admin-brand-assets {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 1rem;
margin: 0 0 1.5rem;
}
.j13-admin-brand-asset {
border: 1px solid var(--j13-border);
border-radius: 6px;
padding: 0.75rem;
}
.j13-admin-brand-preview {
max-width: 8rem;
max-height: 4rem;
object-fit: contain;
display: block;
}
.j13-admin-brand-preview--sm {
max-width: 2rem;
max-height: 2rem;
}
.j13-admin-media-toolbar { .j13-admin-media-toolbar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;