仰望星辰工作室

feat: 管理员专享昵称(发布需验证后台账号 + 前台标识)

F fqh 提交于 2026-09-12 23:10 · 99028bc ·父提交 a5b24a0
feat: 管理员专享昵称(发布需验证后台账号 + 前台标识)

- posts 表新增 is_admin 列(建表语句 + 旧库 ALTER 自动兼容 SQLite/MySQL)
- 后台「网站设置」新增管理员专享昵称配置,支持中英文逗号、分号与换行分隔
- 发帖(表单与 API)命中保留昵称时强制校验后台账号密码,通过才允许发布并标记
- 校验失败按 IP 限流:5 次失败锁定 10 分钟
- 前台卡片与详情页展示「管理员」标识,/api/v1/posts 返回 is_admin 字段
- 保留昵称同时禁止注册,避免冒充官方身份
- 发帖页新增管理员身份验证弹窗,验证后衔接原有验证码流程
11 个文件变更 +288 -21 fangqihang1717@163.com
•README.md +2 -1
•internal/database/database.go +9 -1
•internal/handlers/admin.go +2 -2
•internal/handlers/api.go +21 -2
•internal/handlers/auth.go +5 -0
•internal/handlers/front.go +89 -10
•internal/models/models.go +35 -0
•web/static/app.css +14 -0
•web/static/app.js +74 -2
•web/templates/admin.html +4 -0
•web/templates/front.html +33 -3
变更内容
diff --git a/README.md b/README.md
index 020db2a..630c255 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,7 @@ ClearLove 表白墙是一套面向个人与小型社区的**表白墙 / 匿名
 | 发帖验证 | 点击发布后弹出 4 位数字图形验证码(SVG,一次性消费),上传区支持拖拽与点击 |
 | 互动 | 点赞(按浏览器指纹去重,支持匿名)、评论、举报 |
 | 账号 | 注册 / 登录(可配置邮箱验证码),个人主页可编辑、删除自己的帖子 |
+| 管理员专享昵称 | 后台配置的保留昵称(如「官方」),发布时须验证后台账号密码,帖子显示「管理员」标识,且该昵称禁止被注册 |
 | 展示 | 公告每日一次提示、社区守则、主题背景图、捐赠二维码 |
 
 ### 后台(`/admin`)
@@ -55,7 +56,7 @@ ClearLove 表白墙是一套面向个人与小型社区的**表白墙 / 匿名
 | 帖子管理 | 编辑、删除、按 IP 或浏览器指纹封禁发帖者 |
 | 举报管理 | 人工处理(保留 / 隐藏 / 删除),AI 每 10 分钟自动巡查并留痕 |
 | 用户 / 管理员 | 用户启用禁用;管理员支持 `super` 与自定义权限组(8 个权限粒度) |
-| 网站设置 | 站名、主题与背景、注册与发帖开关、SMTP、AI 接入、插件商店云端地址 |
+| 网站设置 | 站名、主题与背景、注册与发帖开关、SMTP、AI 接入、插件商店云端地址、管理员专享昵称 |
 | 内容运营 | 公告、话题、社区守则 |
 | 插件 | zip 上传安装、启用 / 禁用 / 卸载,**插件商店一键安装云端插件** |
 | 开放能力 | API Key 管理 + `/api/v1/*` RESTful 接口 |
diff --git a/internal/database/database.go b/internal/database/database.go
index 0e572df..2f941ed 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -69,7 +69,8 @@ CREATE TABLE IF NOT EXISTS users (
 CREATE TABLE IF NOT EXISTS posts (
 	id {PK}, user_id {I} DEFAULT 0, nickname VARCHAR(64) DEFAULT '匿名', content {LT},
 	topic_id {I} DEFAULT 0, ip VARCHAR(64) DEFAULT '', fingerprint VARCHAR(128) DEFAULT '',
-	status {I} DEFAULT 1, like_count {I} DEFAULT 0, comment_count {I} DEFAULT 0, created_at VARCHAR(40)
+	status {I} DEFAULT 1, like_count {I} DEFAULT 0, comment_count {I} DEFAULT 0,
+	is_admin {I} DEFAULT 0, created_at VARCHAR(40)
 );
 
 CREATE TABLE IF NOT EXISTS medias (
@@ -125,6 +126,13 @@ func Migrate() error {
 			}
 		}
 	}
+	// 兼容旧库:posts 增加 is_admin 列(列已存在时报错,忽略即可)
+	alter := "ALTER TABLE posts ADD COLUMN is_admin INTEGER DEFAULT 0"
+	if Driver == "mysql" {
+		alter = "ALTER TABLE posts ADD COLUMN is_admin INT DEFAULT 0"
+	}
+	_, _ = DB.Exec(alter)
+
 	// 常用索引(忽略已存在错误)
 	indexes := []string{
 		"CREATE INDEX idx_posts_created ON posts (created_at)",
diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go
index 6bb37fc..d45b2d9 100644
--- a/internal/handlers/admin.go
+++ b/internal/handlers/admin.go
@@ -190,7 +190,7 @@ func AdminSettings(w http.ResponseWriter, r *http.Request) {
 	keys := []string{"site_name", "theme", "theme_bg", "allow_register", "require_login_post",
 		"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_from",
 		"ai_enabled", "ai_base", "ai_key", "ai_model", "ai_autopatrol", "ai_precheck",
-		"update_url", "donate_img", "cloud_api"}
+		"update_url", "donate_img", "cloud_api", "admin_nicknames"}
 	s := map[string]string{}
 	for _, k := range keys {
 		s[k] = models.GetSetting(k)
@@ -206,7 +206,7 @@ func AdminSettingsSave(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	textKeys := []string{"site_name", "theme_bg", "smtp_host", "smtp_port", "smtp_user",
-		"smtp_pass", "smtp_from", "ai_base", "ai_key", "ai_model", "update_url", "donate_img", "cloud_api"}
+		"smtp_pass", "smtp_from", "ai_base", "ai_key", "ai_model", "update_url", "donate_img", "cloud_api", "admin_nicknames"}
 	for _, k := range textKeys {
 		if v := r.FormValue(k); v != "" || k == "theme_bg" || k == "smtp_pass" {
 			_ = models.SetSetting(k, strings.TrimSpace(v))
diff --git a/internal/handlers/api.go b/internal/handlers/api.go
index 1110dc5..5800415 100644
--- a/internal/handlers/api.go
+++ b/internal/handlers/api.go
@@ -157,6 +157,9 @@ func APICreatePost(w http.ResponseWriter, r *http.Request) {
 		Topic    string   `json:"topic"`
 		Images   []string `json:"images"`
 		Videos   []string `json:"videos"`
+		// 使用管理员专享昵称时必填
+		AdminUser string `json:"admin_user"`
+		AdminPass string `json:"admin_pass"`
 	}
 	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
 		fail(w, 400, "JSON 格式错误")
@@ -181,13 +184,29 @@ func APICreatePost(w http.ResponseWriter, r *http.Request) {
 	if nickname == "" {
 		nickname = "匿名"
 	}
+	// 管理员专享昵称:校验后台账号密码
+	isAdmin := 0
+	if models.IsAdminNickname(nickname) {
+		ip := util.ClientIP(r)
+		if adminTryBlocked(ip) {
+			fail(w, 429, "验证失败次数过多,请 10 分钟后再试")
+			return
+		}
+		if !verifyAdminCreds(strings.TrimSpace(body.AdminUser), body.AdminPass) {
+			adminTryFail(ip)
+			fail(w, 403, "「"+nickname+"」为管理员专享昵称,请填写正确的后台账号与密码")
+			return
+		}
+		adminTryReset(ip)
+		isAdmin = 1
+	}
 	topicID := int64(0)
 	if t := util.StripHTML(body.Topic); t != "" {
 		topicID = ensureTopic(t)
 	}
 	res, err := database.DB.Exec(
-		"INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,created_at) VALUES(?,?,?,?,?,?,1,?)",
-		userIDOf(u), nickname, content, topicID, util.ClientIP(r), r.Header.Get("X-Fingerprint"), models.Now())
+		"INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,is_admin,created_at) VALUES(?,?,?,?,?,?,1,?,?)",
+		userIDOf(u), nickname, content, topicID, util.ClientIP(r), r.Header.Get("X-Fingerprint"), isAdmin, models.Now())
 	if err != nil {
 		fail(w, 500, "发布失败")
 		return
diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go
index 078130a..2dc5a67 100644
--- a/internal/handlers/auth.go
+++ b/internal/handlers/auth.go
@@ -88,6 +88,11 @@ func RegisterSubmit(w http.ResponseWriter, r *http.Request) {
 		q("用户名需为 3-20 位字母、数字、下划线或中文")
 		return
 	}
+	// 管理员专享昵称不允许被注册,避免冒充官方身份
+	if models.IsAdminNickname(username) {
+		q("该名称为管理员专享,请更换用户名")
+		return
+	}
 	if _, err := mail.ParseAddress(email); err != nil {
 		q("邮箱格式不正确")
 		return
diff --git a/internal/handlers/front.go b/internal/handlers/front.go
index 778a0bc..2a6b504 100644
--- a/internal/handlers/front.go
+++ b/internal/handlers/front.go
@@ -6,6 +6,8 @@ import (
 	"net/http"
 	"path"
 	"strings"
+	"sync"
+	"time"
 
 	"clearlove/internal/ai"
 	"clearlove/internal/database"
@@ -15,6 +17,61 @@ import (
 	"clearlove/internal/util"
 )
 
+// ---------- 管理员专享昵称:凭证校验与限流 ----------
+
+// adminTry 单个 IP 的失败计数(防止在线爆破后台密码)
+type adminTry struct {
+	Count int
+	Until time.Time
+}
+
+var (
+	adminTryMu sync.Mutex
+	adminTries = map[string]adminTry{}
+)
+
+// verifyAdminCreds 校验后台管理员账号密码(仅校验,不建立会话)
+func verifyAdminCreds(username, password string) bool {
+	if username == "" || password == "" {
+		return false
+	}
+	var hash string
+	if err := database.DB.QueryRow("SELECT password FROM admins WHERE username=?", username).Scan(&hash); err != nil {
+		return false
+	}
+	return util.CheckPassword(hash, password)
+}
+
+// adminTryBlocked 是否因失败次数过多被暂时锁定(5 次 / 10 分钟)
+func adminTryBlocked(ip string) bool {
+	adminTryMu.Lock()
+	defer adminTryMu.Unlock()
+	t, ok := adminTries[ip]
+	if !ok {
+		return false
+	}
+	if time.Now().After(t.Until) {
+		delete(adminTries, ip)
+		return false
+	}
+	return t.Count >= 5
+}
+
+func adminTryFail(ip string) {
+	adminTryMu.Lock()
+	defer adminTryMu.Unlock()
+	t := adminTries[ip]
+	t.Count++
+	t.Until = time.Now().Add(10 * time.Minute)
+	adminTries[ip] = t
+}
+
+func adminTryReset(ip string) {
+	adminTryMu.Lock()
+	defer adminTryMu.Unlock()
+	delete(adminTries, ip)
+}
+
 // CommentItem 评论预览项
 type CommentItem struct {
 	Nickname  string `json:"nickname"`
@@ -33,6 +90,7 @@ type Card struct {
 	Videos       []string      `json:"videos"`
 	Likes        int64         `json:"likes"`
 	CommentCount int64         `json:"comment_count"`
+	IsAdmin      int           `json:"is_admin"`
 	Comments     []CommentItem `json:"comments"`
 	CreatedAt    string        `json:"created_at"`
 }
@@ -54,7 +112,7 @@ func latestNotice() (int64, string, string) {
 func fetchCards(before int64, limit int, topic string) []Card {
 	q := `
 		SELECT p.id,p.user_id,IFNULL(p.nickname,'匿名'),IFNULL(p.content,''),IFNULL(t.name,''),
-		       p.like_count,p.comment_count,p.created_at
+		       p.like_count,p.comment_count,IFNULL(p.is_admin,0),p.created_at
 		FROM posts p LEFT JOIN topics t ON t.id=p.topic_id
 		WHERE p.status=1 AND (? <= 0 OR p.id < ?)`
 	args := []any{before, before}
@@ -75,7 +133,7 @@ func fetchCards(before int64, limit int, topic string) []Card {
 	for rows.Next() {
 		c := Card{Images: []string{}, Videos: []string{}, Comments: []CommentItem{}}
 		if err := rows.Scan(&c.ID, &c.UserID, &c.Nickname, &c.Content, &c.Topic,
-			&c.Likes, &c.CommentCount, &c.CreatedAt); err == nil {
+			&c.Likes, &c.CommentCount, &c.IsAdmin, &c.CreatedAt); err == nil {
 			order = append(order, c.ID)
 			byID[c.ID] = &c
 		}
@@ -210,8 +268,9 @@ func singleCard(id int64) *Card {
 	}
 	c := &Card{
 		ID: p.ID, UserID: p.UserID, Nickname: p.Nickname, Content: p.Content,
-		Likes: p.LikeCount, CommentCount: p.CommentCount, CreatedAt: p.CreatedAt,
-		Images: []string{}, Videos: []string{}, Comments: []CommentItem{},
+		Likes: p.LikeCount, CommentCount: p.CommentCount, IsAdmin: p.IsAdmin,
+		CreatedAt: p.CreatedAt,
+		Images:    []string{}, Videos: []string{}, Comments: []CommentItem{},
 	}
 	c.Topic = models.QueryStr("SELECT name FROM topics WHERE id=?", p.TopicID)
 	rows, err := database.DB.Query("SELECT type,path FROM medias WHERE post_id=? ORDER BY id", id)
@@ -267,6 +326,8 @@ func ComposePage(w http.ResponseWriter, r *http.Request) {
 		"maxImages":    15, "maxVideos": 3,
 		// AI 发帖预审是否生效(开启且模型已接入)——前端据此展示审核动画
 		"aiPrecheck": models.GetSetting("ai_precheck") == "1" && ai.Enabled(),
+		// 管理员专享昵称(逗号连接),前端据此在提交时弹出后台账号验证弹窗
+		"adminNicks": strings.Join(models.AdminNicknames(), ","),
 	})
 }
 
@@ -309,6 +370,23 @@ func ComposeSubmit(w http.ResponseWriter, r *http.Request) {
 		nickname = "匿名"
 	}
 
+	// 管理员专享昵称:必须校验后台账号密码,通过后才允许发布并标记为管理员帖
+	isAdmin := 0
+	if models.IsAdminNickname(nickname) {
+		ip := util.ClientIP(r)
+		if adminTryBlocked(ip) {
+			fail(w, 429, "验证失败次数过多,请 10 分钟后再试")
+			return
+		}
+		if !verifyAdminCreds(strings.TrimSpace(r.FormValue("admin_user")), r.FormValue("admin_pass")) {
+			adminTryFail(ip)
+			fail(w, 403, "「"+nickname+"」为管理员专享昵称,请填写正确的后台账号与密码")
+			return
+		}
+		adminTryReset(ip)
+		isAdmin = 1
+	}
+
 	// 先全量校验文件(数量/大小/格式),再落盘,避免部分文件写入后失败
 	imgs := r.MultipartForm.File["images"]
 	vids := r.MultipartForm.File["videos"]
@@ -385,8 +463,8 @@ func ComposeSubmit(w http.ResponseWriter, r *http.Request) {
 
 	// 入库
 	res, err := database.DB.Exec(
-		"INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,created_at) VALUES(?,?,?,?,?,?,1,?)",
-		userIDOf(user), nickname, content, topicID, util.ClientIP(r), fp, models.Now())
+		"INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,is_admin,created_at) VALUES(?,?,?,?,?,?,1,?,?)",
+		userIDOf(user), nickname, content, topicID, util.ClientIP(r), fp, isAdmin, models.Now())
 	if err != nil {
 		fail(w, 500, "发布失败,请稍后重试")
 		return
@@ -517,8 +595,8 @@ func PostEditSave(w http.ResponseWriter, r *http.Request) {
 func loadPost(id int64) *models.Post {
 	p := &models.Post{}
 	err := database.DB.QueryRow(
-		"SELECT id,user_id,IFNULL(nickname,'匿名'),IFNULL(content,''),topic_id,status,like_count,comment_count,IFNULL(created_at,'') FROM posts WHERE id=?", id,
-	).Scan(&p.ID, &p.UserID, &p.Nickname, &p.Content, &p.TopicID, &p.Status, &p.LikeCount, &p.CommentCount, &p.CreatedAt)
+		"SELECT id,user_id,IFNULL(nickname,'匿名'),IFNULL(content,''),topic_id,status,like_count,comment_count,IFNULL(is_admin,0),IFNULL(created_at,'') FROM posts WHERE id=?", id,
+	).Scan(&p.ID, &p.UserID, &p.Nickname, &p.Content, &p.TopicID, &p.Status, &p.LikeCount, &p.CommentCount, &p.IsAdmin, &p.CreatedAt)
 	if err != nil {
 		return nil
 	}
@@ -570,7 +648,7 @@ func Profile(w http.ResponseWriter, r *http.Request) {
 		where = "user_id=?"
 	}
 	rows, err := database.DB.Query(
-		"SELECT id,IFNULL(nickname,'匿名'),IFNULL(content,''),like_count,comment_count,IFNULL(created_at,'') FROM posts WHERE "+where+" ORDER BY id DESC LIMIT 100", uid)
+		"SELECT id,IFNULL(nickname,'匿名'),IFNULL(content,''),like_count,comment_count,IFNULL(is_admin,0),IFNULL(created_at,'') FROM posts WHERE "+where+" ORDER BY id DESC LIMIT 100", uid)
 	if err != nil {
 		http.Error(w, "查询失败", http.StatusInternalServerError)
 		return
@@ -582,12 +660,13 @@ func Profile(w http.ResponseWriter, r *http.Request) {
 		Content      string
 		LikeCount    int64
 		CommentCount int64
+		IsAdmin      int
 		CreatedAt    string
 	}
 	var posts []own
 	for rows.Next() {
 		var p own
-		_ = rows.Scan(&p.ID, &p.Nickname, &p.Content, &p.LikeCount, &p.CommentCount, &p.CreatedAt)
+		_ = rows.Scan(&p.ID, &p.Nickname, &p.Content, &p.LikeCount, &p.CommentCount, &p.IsAdmin, &p.CreatedAt)
 		posts = append(posts, p)
 	}
 	Render(w, r, "layout.html", "pg_profile", map[string]any{
diff --git a/internal/models/models.go b/internal/models/models.go
index 470ecad..26bb491 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -5,6 +5,7 @@ package models
 import (
 	"database/sql"
 	"encoding/json"
+	"strings"
 	"sync"
 	"time"
 
@@ -71,9 +72,43 @@ type Post struct {
 	Status       int // 1 可见 0 隐藏(AI/管理员处理)
 	LikeCount    int64
 	CommentCount int64
+	IsAdmin      int // 1 = 管理员专享昵称发布(已通过后台账号验证)
 	CreatedAt    string
 }
 
+// AdminNicknames 后台配置的「管理员专享昵称」列表。
+// 支持中英文逗号、分号与换行分隔,用于防止他人冒充官方身份。
+func AdminNicknames() []string {
+	raw := GetSetting("admin_nicknames")
+	if raw == "" {
+		return nil
+	}
+	fields := strings.FieldsFunc(raw, func(r rune) bool {
+		return r == ',' || r == ',' || r == ';' || r == ';' || r == '\n' || r == '\r'
+	})
+	out := make([]string, 0, len(fields))
+	for _, f := range fields {
+		if s := strings.TrimSpace(f); s != "" {
+			out = append(out, s)
+		}
+	}
+	return out
+}
+
+// IsAdminNickname 昵称是否属于管理员专享(忽略大小写与首尾空白)
+func IsAdminNickname(name string) bool {
+	name = strings.TrimSpace(name)
+	if name == "" {
+		return false
+	}
+	for _, n := range AdminNicknames() {
+		if strings.EqualFold(n, name) {
+			return true
+		}
+	}
+	return false
+}
+
 var (
 	setCache   sync.Map // 设置项内存缓存
 	setCacheMu sync.Mutex
diff --git a/web/static/app.css b/web/static/app.css
index aa62660..1ff36f2 100644
--- a/web/static/app.css
+++ b/web/static/app.css
@@ -255,6 +255,20 @@ textarea { resize: vertical; }
 .modal-captcha input { width: 130px; text-align: center; letter-spacing: 4px; font-size: 16px; }
 .captcha-modal-box .form-err { margin: 10px 0 0; }
 .captcha-modal-box .btn-row { margin-top: 14px; }
+
+/* ---------------- 管理员专享昵称 ---------------- */
+.nick-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
+.admin-badge {
+  display: inline-flex; align-items: center; flex: none;
+  padding: 1px 8px; border-radius: 999px;
+  font-size: 11px; font-weight: 600; letter-spacing: .5px;
+  background: linear-gradient(135deg, var(--clv-accent), var(--clv-accent-2));
+  color: #fff;
+}
+.admin-verify-box { width: min(360px, 100%); }
+.admin-verify-box h3 { margin-bottom: 6px; }
+.admin-verify-box > .muted { font-size: 13px; margin: 0 0 6px; }
+.admin-verify-box > .muted b { color: var(--clv-accent); }
 .profile-head { display: flex; align-items: center; gap: 14px; }
 .profile-head .meta { flex: 1; }
 .profile-head h2 { margin: 0; font-size: 18px; }
diff --git a/web/static/app.js b/web/static/app.js
index 4aa4e29..7583f71 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -130,7 +130,11 @@
     } else {
       nick = el('span', 'nickname', p.nickname || '匿名');
     }
-    meta.appendChild(nick);
+    var nickRow = el('div', 'nick-row');
+    nickRow.appendChild(nick);
+    // 管理员专享昵称发布的帖子:显示「管理员」标识
+    if (p.is_admin) nickRow.appendChild(el('span', 'admin-badge', '管理员'));
+    meta.appendChild(nickRow);
     var t = el('time', '', timeText(p.created_at));
     meta.appendChild(t);
     head.appendChild(meta);
@@ -347,6 +351,16 @@
       else toast(msg);
     }
 
+    /* ---- 管理员专享昵称:命中时需验证后台账号 ---- */
+    var adminNicks = (form.dataset.adminNicks || '').split(',').map(function (s) {
+      return s.trim().toLowerCase();
+    }).filter(function (s) { return s; });
+    var adminAuth = { user: '', pass: '' }; // 本次发布携带的后台凭证
+
+    function isReservedNick(name) {
+      return adminNicks.indexOf(String(name || '').trim().toLowerCase()) >= 0;
+    }
+
     /* ---- 媒体上传:拖拽(PC)+ 点击选择(移动端/PC) ---- */
     var dropZone = document.getElementById('drop-zone');
     var input = document.getElementById('media-input');
@@ -482,6 +496,54 @@
       if (e.key === 'Escape' && modal && !modal.hidden) closeCaptcha();
     });
 
+    /* ---- 管理员身份验证弹窗 ---- */
+    var avModal = document.getElementById('admin-verify-modal');
+    var avNick = document.getElementById('admin-verify-nick');
+    var avUser = document.getElementById('admin-user');
+    var avPass = document.getElementById('admin-pass');
+    var avErr = document.getElementById('admin-verify-err');
+    var avOk = document.getElementById('admin-verify-ok');
+    var avCancel = document.getElementById('admin-verify-cancel');
+
+    function closeAdminVerify() { if (avModal) avModal.hidden = true; }
+
+    function openAdminVerify(nick) {
+      if (!avModal) return;
+      if (avNick) avNick.textContent = nick;
+      if (avErr) avErr.hidden = true;
+      if (avUser) avUser.value = '';
+      if (avPass) avPass.value = '';
+      avModal.hidden = false;
+      if (avUser) setTimeout(function () { avUser.focus(); }, 50);
+    }
+
+    if (avCancel) avCancel.addEventListener('click', closeAdminVerify);
+    if (avModal) {
+      var avMask = avModal.querySelector('.modal-mask');
+      if (avMask) avMask.addEventListener('click', closeAdminVerify);
+    }
+    if (avPass) {
+      avPass.addEventListener('keydown', function (e) {
+        if (e.key === 'Enter') { e.preventDefault(); if (avOk) avOk.click(); }
+      });
+    }
+    document.addEventListener('keydown', function (e) {
+      if (e.key === 'Escape' && avModal && !avModal.hidden) closeAdminVerify();
+    });
+    if (avOk) {
+      avOk.addEventListener('click', function () {
+        var u = (avUser ? avUser.value : '').trim();
+        var p = avPass ? avPass.value : '';
+        if (!u || !p) {
+          if (avErr) { avErr.textContent = '请输入后台账号与密码'; avErr.hidden = false; }
+          return;
+        }
+        adminAuth = { user: u, pass: p };
+        closeAdminVerify();
+        openCaptcha(); // 验证通过后继续走验证码流程
+      });
+    }
+
     // 验证码通过后真正提交(XHR 带上传进度 + AI 预审动画)
     function doSubmit(code) {
       if (captchaErr) captchaErr.hidden = true;
@@ -497,6 +559,9 @@
       fd.set('captcha_id', captchaBox ? (captchaBox.dataset.id || '') : '');
       fd.set('captcha_code', code);
       fd.set('fingerprint', fingerprint());
+      // 管理员专享昵称:后台账号凭证(服务端强制校验)
+      fd.set('admin_user', adminAuth.user || '');
+      fd.set('admin_pass', adminAuth.pass || '');
 
       var xhr = new XMLHttpRequest();
       xhr.open('POST', '/compose');
@@ -535,12 +600,19 @@
       xhr.send(fd);
     }
 
-    // 点击「发布」:先做基础校验,再弹出验证码
+    // 点击「发布」:基础校验 → (管理员专享昵称时)验证后台账号 → 验证码
     form.addEventListener('submit', function (ev) {
       ev.preventDefault();
       if (errBox) errBox.hidden = true;
       var content = form.querySelector('[name=content]');
       if (content && !content.value.trim()) { showErr('请填写内容'); return; }
+      adminAuth = { user: '', pass: '' };
+      var nickInput = form.querySelector('[name=nickname]');
+      var nick = nickInput ? nickInput.value : '';
+      if (isReservedNick(nick)) {
+        openAdminVerify(String(nick).trim() || '匿名');
+        return;
+      }
       openCaptcha();
     });
 
diff --git a/web/templates/admin.html b/web/templates/admin.html
index affae35..f35b2cf 100644
--- a/web/templates/admin.html
+++ b/web/templates/admin.html
@@ -149,6 +149,10 @@
     <label>检查更新接口地址<input type="text" name="update_url" value="{{.s.update_url}}"></label>
     <label>插件商店云端地址<input type="text" name="cloud_api" value="{{.s.cloud_api}}" placeholder="https://clearlove.kazx.top"></label>
     <p class="muted">插件商店从该地址拉取插件市场数据,默认使用官方云端(https://clearlove.kazx.top)。</p>
+    <label>管理员专享昵称(多个用逗号或换行分隔)
+      <textarea name="admin_nicknames" rows="3" placeholder="例如:官方, 管理员, ClearLove">{{.s.admin_nicknames}}</textarea>
+    </label>
+    <p class="muted">使用这些昵称发帖时必须验证后台管理员账号与密码,通过后帖子会显示「管理员」标识;这些昵称同时禁止被注册。</p>
     <label>捐赠图片地址<input type="text" name="donate_img" value="{{.s.donate_img}}"></label>
   </div>
 
diff --git a/web/templates/front.html b/web/templates/front.html
index 41ef458..0f53bad 100644
--- a/web/templates/front.html
+++ b/web/templates/front.html
@@ -18,7 +18,10 @@
   <div class="card-head">
     <span class="avatar">{{substr0 .Nickname}}</span>
     <div class="meta">
-      {{if .UserID}}<a class="nickname" href="/u/{{.UserID}}">{{.Nickname}}</a>{{else}}<span class="nickname">{{.Nickname}}</span>{{end}}
+      <div class="nick-row">
+        {{if .UserID}}<a class="nickname" href="/u/{{.UserID}}">{{.Nickname}}</a>{{else}}<span class="nickname">{{.Nickname}}</span>{{end}}
+        {{if .IsAdmin}}<span class="admin-badge" title="管理员专享昵称发布">管理员</span>{{end}}
+      </div>
       <time>{{datefmt .CreatedAt}}</time>
     </div>
     {{if $.canEdit}}
@@ -79,7 +82,8 @@
 
 {{/* ============ 发帖页 ============ */}}
 {{define "pg_compose"}}
-<form class="compose card" id="compose-form" method="post" action="/compose" enctype="multipart/form-data">
+<form class="compose card" id="compose-form" method="post" action="/compose" enctype="multipart/form-data"
+      data-admin-nicks="{{.adminNicks}}">
   <h2>发布新帖</h2>
   <input type="hidden" name="_csrf" value="{{.csrf}}">
   <label>昵称
@@ -138,6 +142,26 @@
   </div>
 </div>
 
+{{/* 管理员专享昵称:点击发布后弹出后台账号验证 */}}
+<div class="modal" id="admin-verify-modal" hidden>
+  <div class="modal-mask"></div>
+  <div class="modal-box admin-verify-box">
+    <h3>管理员身份验证</h3>
+    <p class="muted">昵称「<b id="admin-verify-nick"></b>」为管理员专享,请验证后台账号后发布</p>
+    <label>后台账号
+      <input type="text" id="admin-user" maxlength="64" autocomplete="off" placeholder="管理员用户名">
+    </label>
+    <label>后台密码
+      <input type="password" id="admin-pass" maxlength="128" autocomplete="off" placeholder="管理员密码">
+    </label>
+    <p class="form-err" id="admin-verify-err" hidden></p>
+    <div class="btn-row">
+      <button type="button" class="btn btn-ghost btn-block" id="admin-verify-cancel">取消</button>
+      <button type="button" class="btn btn-primary btn-block" id="admin-verify-ok">验证并继续</button>
+    </div>
+  </div>
+</div>
+
 {{if .aiPrecheck}}
 {{/* 发帖 AI 预审加载动画 */}}
 <div class="scan-overlay" id="ai-scan" hidden>
@@ -232,7 +256,13 @@
   <article class="card post-card">
     <div class="card-head">
       <span class="avatar">{{substr0 .Nickname}}</span>
-      <div class="meta"><span class="nickname">{{.Nickname}}</span><time>{{datefmt .CreatedAt}}</time></div>
+      <div class="meta">
+        <div class="nick-row">
+          <span class="nickname">{{.Nickname}}</span>
+          {{if .IsAdmin}}<span class="admin-badge" title="管理员专享昵称发布">管理员</span>{{end}}
+        </div>
+        <time>{{datefmt .CreatedAt}}</time>
+      </div>
       {{if or $.self $.admin}}
       <div class="row-actions">
         <a class="mini-btn" href="/post/{{.ID}}/edit">编辑</a>