增加 @提及、搜索筛选与找回密码,并将右栏热门改为正在聊。

补齐评论提及补全与通知、按作者/板块/仅标题搜索,以及邮箱验证码重置密码;同时修复 Go 提及正则并优化侧栏悬停样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-07 07:39:33 +08:00
parent 10185178e2
commit 94f6a9666b
26 changed files with 1315 additions and 153 deletions

View File

@@ -2,6 +2,7 @@ package service
import (
"crypto/rand"
"errors"
"math/big"
"strings"
"sync"
@@ -14,9 +15,12 @@ const (
emailCodeLen = 6
emailCodeTTL = 10 * time.Minute
emailCodeCooldown = 60 * time.Second
EmailCodePurposeRegister = "register"
EmailCodePurposeReset = "reset"
)
// EmailCodeLen 注册邮箱验证码位数(供 API 告知前端)
// EmailCodeLen 邮箱验证码位数(供 API 告知前端)
const EmailCodeLen = emailCodeLen
type emailCodeEntry struct {
@@ -25,7 +29,7 @@ type emailCodeEntry struct {
sentAt time.Time
}
// EmailCodeService 注册邮箱验证码
// EmailCodeService 邮箱验证码(按 purpose 隔离)
type EmailCodeService struct {
mu sync.Mutex
entries map[string]emailCodeEntry
@@ -41,20 +45,45 @@ func NewEmailCodeService(mail *MailService) *EmailCodeService {
return s
}
// SendRegisterCode 向邮箱发送注册验证码
func emailCodeKey(purpose, email string) string {
return purpose + ":" + NormalizeEmail(email)
}
// SendRegisterCode 向邮箱发送注册验证码(邮箱须未注册)
func (s *EmailCodeService) SendRegisterCode(email string) error {
return s.sendCode(EmailCodePurposeRegister, email)
}
// SendResetCode 向邮箱发送重置密码验证码(邮箱须已注册;不存在时仍返回成功以防枚举)
func (s *EmailCodeService) SendResetCode(email string) error {
return s.sendCode(EmailCodePurposeReset, email)
}
func (s *EmailCodeService) sendCode(purpose, email string) error {
email = NormalizeEmail(email)
if err := ValidateEmail(email); err != nil {
return err
}
var exist model.User
if err := model.DB.Where("email = ?", email).First(&exist).Error; err == nil {
return ErrEmailExists
found := model.DB.Where("email = ?", email).First(&exist).Error == nil
switch purpose {
case EmailCodePurposeRegister:
if found {
return ErrEmailExists
}
case EmailCodePurposeReset:
if !found {
// 防邮箱枚举:假装已发送
return nil
}
default:
return errors.New("无效的验证码用途")
}
key := emailCodeKey(purpose, email)
s.mu.Lock()
if prev, ok := s.entries[email]; ok && time.Since(prev.sentAt) < emailCodeCooldown {
if prev, ok := s.entries[key]; ok && time.Since(prev.sentAt) < emailCodeCooldown {
s.mu.Unlock()
return ErrEmailCodeCooldown
}
@@ -69,13 +98,18 @@ func (s *EmailCodeService) SendRegisterCode(email string) error {
if s.mail != nil && s.mail.settings != nil {
siteName = s.mail.settings.SiteBranding().Name
}
subject, textBody, htmlBody := BuildRegisterCodeMail(siteName, code, int(emailCodeTTL.Minutes()))
var subject, textBody, htmlBody string
if purpose == EmailCodePurposeReset {
subject, textBody, htmlBody = BuildResetCodeMail(siteName, code, int(emailCodeTTL.Minutes()))
} else {
subject, textBody, htmlBody = BuildRegisterCodeMail(siteName, code, int(emailCodeTTL.Minutes()))
}
if err := s.mail.SendHTML(email, subject, textBody, htmlBody); err != nil {
return err
}
s.mu.Lock()
s.entries[email] = emailCodeEntry{
s.entries[key] = emailCodeEntry{
code: code,
expiresAt: time.Now().Add(emailCodeTTL),
sentAt: time.Now(),
@@ -84,20 +118,26 @@ func (s *EmailCodeService) SendRegisterCode(email string) error {
return nil
}
// Verify 校验邮箱验证码(一次性)
// Verify 校验邮箱验证码(一次性);兼容旧调用 Verify(email, code) 视为注册用途
func (s *EmailCodeService) Verify(email, code string) bool {
return s.VerifyPurpose(EmailCodePurposeRegister, email, code)
}
// VerifyPurpose 按用途校验验证码(一次性)
func (s *EmailCodeService) VerifyPurpose(purpose, email, code string) bool {
email = NormalizeEmail(email)
code = strings.TrimSpace(code)
if email == "" || code == "" {
if purpose == "" || email == "" || code == "" {
return false
}
key := emailCodeKey(purpose, email)
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.entries[email]
entry, ok := s.entries[key]
if !ok {
return false
}
delete(s.entries, email)
delete(s.entries, key)
if time.Now().After(entry.expiresAt) {
return false
}
@@ -109,9 +149,9 @@ func (s *EmailCodeService) cleanup() {
for range ticker.C {
now := time.Now()
s.mu.Lock()
for email, entry := range s.entries {
for key, entry := range s.entries {
if now.After(entry.expiresAt) {
delete(s.entries, email)
delete(s.entries, key)
}
}
s.mu.Unlock()

View File

@@ -93,6 +93,89 @@ func BuildRegisterCodeMail(siteName, code string, ttlMinutes int) (subject, text
return subject, textBody, htmlBody
}
// BuildResetCodeMail 生成重置密码验证码邮件
func BuildResetCodeMail(siteName, code string, ttlMinutes int) (subject, textBody, htmlBody string) {
siteName = strings.TrimSpace(siteName)
if siteName == "" {
siteName = "姜十三论坛"
}
if ttlMinutes <= 0 {
ttlMinutes = 10
}
subject = fmt.Sprintf("【%s】重置密码验证码", siteName)
spaced := strings.Join(strings.Split(code, ""), " ")
textBody = fmt.Sprintf(
"你好,\n\n你正在重置 %s 的登录密码。请在页面填写以下验证码:\n\n%s\n\n共 %d 位数字)\n\n有效期%d 分钟。\n如非本人操作请忽略本邮件账号仍然安全。\n\n— %s\n",
siteName, spaced, len(code), ttlMinutes, siteName,
)
safeSite := html.EscapeString(siteName)
safeCode := html.EscapeString(code)
preheader := html.EscapeString(fmt.Sprintf("重置 %s 密码:请填写邮件中的验证码,有效期 %d 分钟。", siteName, ttlMinutes))
htmlBody = fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>%s</title>
</head>
<body style="margin:0;padding:0;background:#f5f7fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;color:#1f2937;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;mso-hide:all;">%s</div>
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background:#f5f7fa;padding:28px 12px;">
<tr>
<td align="center">
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="max-width:520px;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;">
<tr>
<td style="padding:20px 28px;background:linear-gradient(135deg,#18a058,#138f4c);color:#ffffff;">
<div style="font-size:18px;font-weight:700;letter-spacing:0.02em;">%s</div>
<div style="margin-top:4px;font-size:13px;opacity:0.92;">重置密码验证</div>
</td>
</tr>
<tr>
<td style="padding:28px;">
<p style="margin:0 0 12px;font-size:15px;line-height:1.6;">你好,</p>
<p style="margin:0 0 20px;font-size:14px;line-height:1.7;color:#4b5563;">你正在重置 <strong style="color:#111827;">%s</strong> 的登录密码。请在页面输入下方验证码:</p>
<div style="margin:0 0 8px;text-align:center;font-size:12px;color:#6b7280;letter-spacing:0.08em;">验 证 码</div>
<div style="margin:0 auto 8px;max-width:280px;padding:16px 12px;text-align:center;background:#edfbf3;border:1px solid rgba(24,160,88,0.28);border-radius:10px;font-size:28px;font-weight:700;letter-spacing:0.35em;color:#138f4c;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">
%s
</div>
<p style="margin:0 0 20px;text-align:center;font-size:12px;color:#9ca3af;">共 %d 位数字,请完整输入</p>
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="margin:0 0 20px;background:#f8fafc;border-radius:8px;">
<tr>
<td style="padding:12px 14px;font-size:13px;line-height:1.6;color:#4b5563;">
<strong style="color:#111827;">有效期</strong>%d 分钟<br />
超时请返回页面重新获取验证码。
</td>
</tr>
</table>
<p style="margin:0;font-size:12px;line-height:1.6;color:#9ca3af;">如非本人操作,请忽略本邮件。请勿将验证码告知他人。</p>
</td>
</tr>
<tr>
<td style="padding:14px 28px;border-top:1px solid #f1f5f9;font-size:12px;color:#9ca3af;text-align:center;">
此邮件由 %s 自动发送,请勿直接回复
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`,
html.EscapeString(subject),
preheader,
safeSite,
safeSite,
safeCode,
len(code),
ttlMinutes,
safeSite,
)
return subject, textBody, htmlBody
}
// BuildReplyMail 生成「收到新回复」提醒邮件
// displayFloor 为页面可见顶层楼号;底部展示帖子主题,不展示路径 URL。
func BuildReplyMail(siteName, authorName, postTitle string, displayFloor int, isNested bool, excerpt, link string) (subject, textBody, htmlBody string) {

65
service/mention.go Normal file
View File

@@ -0,0 +1,65 @@
package service
import (
"regexp"
"strings"
"git.iioio.com/freefire/jiang13-forum/model"
)
const maxMentionsPerContent = 10
// 与前端 highlightMentions 字符集对齐(字母数字下划线中文,兼容历史 -
// Go RE2 不支持 JS 的 \uXXXX需用 \x{HHHH}
var mentionPattern = regexp.MustCompile(`@([0-9A-Za-z_\x{4e00}-\x{9fa5}-]+)`)
// ExtractMentionNames 从纯文本提取 @提及名(去重、保序)
func ExtractMentionNames(text string) []string {
matches := mentionPattern.FindAllStringSubmatch(text, -1)
if len(matches) == 0 {
return nil
}
seen := make(map[string]struct{}, len(matches))
out := make([]string, 0, len(matches))
for _, m := range matches {
name := strings.TrimSpace(m[1])
if name == "" {
continue
}
key := strings.ToLower(name)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, name)
if len(out) >= maxMentionsPerContent {
break
}
}
return out
}
// ResolveMentionUserIDs 将提及名解析为用户 ID优先 username其次 nickname排除 excludeUserID
func ResolveMentionUserIDs(names []string, excludeUserID uint) []uint {
if len(names) == 0 {
return nil
}
ids := make([]uint, 0, len(names))
seen := make(map[uint]struct{}, len(names))
for _, name := range names {
var u model.User
err := model.DB.Select("id").Where("username = ?", name).First(&u).Error
if err != nil {
err = model.DB.Select("id").Where("nickname = ?", name).First(&u).Error
}
if err != nil || u.ID == 0 || u.ID == excludeUserID {
continue
}
if _, ok := seen[u.ID]; ok {
continue
}
seen[u.ID] = struct{}{}
ids = append(ids, u.ID)
}
return ids
}

14
service/mention_test.go Normal file
View File

@@ -0,0 +1,14 @@
package service
import (
"reflect"
"testing"
)
func TestExtractMentionNames(t *testing.T) {
got := ExtractMentionNames("hi @alice 和 @小明_x 以及 @bob-1")
want := []string{"alice", "小明_x", "bob-1"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}

View File

@@ -43,6 +43,15 @@ func (s *NotifyService) AsyncNotifyCommentPublished(comment *model.Comment) {
s.goNotify(func() { s.NotifyCommentPublished(&cp) })
}
// AsyncNotifyCommentMentions 异步:评论公开后通知被 @ 的用户
func (s *NotifyService) AsyncNotifyCommentMentions(comment *model.Comment) {
if s == nil || comment == nil {
return
}
cp := *comment
s.goNotify(func() { s.NotifyCommentMentions(&cp) })
}
// AsyncNotifyPendingPost 异步:待审帖通知管理员
func (s *NotifyService) AsyncNotifyPendingPost(post *model.Post) {
if s == nil || post == nil {
@@ -92,6 +101,41 @@ func (s *NotifyService) NotifyCommentPublished(comment *model.Comment) {
s.sendReplyMail(toUserID, authorName, title, comment.PostID, displayFloor, isNested, comment.Content)
}
// NotifyCommentMentions 评论公开后通知被 @提及的用户(跳过已收到回复通知的人)
func (s *NotifyService) NotifyCommentMentions(comment *model.Comment) {
if s == nil || comment == nil || comment.Status != model.ContentStatusPublished {
return
}
names := ExtractMentionNames(comment.Content)
ids := ResolveMentionUserIDs(names, comment.UserID)
if len(ids) == 0 {
return
}
post, err := s.loadPost(comment.PostID)
if err != nil {
return
}
// 已作为回复对象收到通知的用户不再重复发 mention
replyTo, _ := s.resolveReplyRecipient(comment, post)
authorName := s.commentAuthorName(comment)
title := post.Title
if title == "" {
title = "未知帖子"
}
displayFloor := s.resolveDisplayFloor(comment)
pid := comment.PostID
subject := "有人 @了你"
content := FormatMentionContent(authorName, title, displayFloor)
for _, uid := range ids {
if uid == 0 || uid == comment.UserID || uid == replyTo {
continue
}
_, _ = s.messages.SendSystem(uid, subject, content, model.MessageKindMention, &pid, nil)
}
}
// NotifyPendingPost 新帖进入待审时通知全部管理员
func (s *NotifyService) NotifyPendingPost(post *model.Post) {
if s == nil || post == nil || post.Status != model.ContentStatusPending {
@@ -295,6 +339,11 @@ func FormatReplyContent(authorName, postTitle string, displayFloor int, isNested
return fmt.Sprintf("%s 在《%s》发表了 #%d 楼。", authorName, postTitle, displayFloor)
}
// FormatMentionContent @提及站内通知正文
func FormatMentionContent(authorName, postTitle string, displayFloor int) string {
return fmt.Sprintf("%s 在《%s》#%d 楼中提到了你。", authorName, postTitle, displayFloor)
}
// FormatPendingPostContent 待审帖站内私信正文
func FormatPendingPostContent(authorName, postTitle string, postID uint) string {
return fmt.Sprintf(

View File

@@ -35,6 +35,8 @@ type PostListQuery struct {
Size int
Keyword string
Tag string // 精确标签筛选(整枚匹配,不走 keyword LIKE
Author string // 作者用户名或昵称(解析为 UserID
TitleOnly bool // 关键词仅匹配标题
Sort string // latest | reply | hot
ViewerID uint // 当前查看者(用于 pending 仅作者可见)
ViewerIsAdmin bool
@@ -127,14 +129,29 @@ func parseSQLiteTime(s string) (time.Time, bool) {
return time.Time{}, false
}
// HotPosts 近期活跃讨论(近 7 日有公开回复,按最后回复时间倒序)
func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
if limit <= 0 {
limit = 10
}
since := time.Now().Add(-7 * 24 * time.Hour)
var posts []model.Post
err := model.DB.Preload("User").Preload("Board").
Where("status = ?", model.ContentStatusPublished).
Order("like_count desc, view_count desc").Limit(limit).Find(&posts).Error
Where(`EXISTS (
SELECT 1 FROM comments
WHERE comments.post_id = posts.id
AND comments.deleted_at IS NULL
AND comments.status = ?
AND comments.created_at >= ?
)`, model.ContentStatusPublished, since).
Order(`(
SELECT MAX(created_at) FROM comments
WHERE comments.post_id = posts.id
AND comments.deleted_at IS NULL
AND comments.status = 'published'
) DESC`).
Limit(limit).Find(&posts).Error
if err != nil {
return nil, err
}
@@ -143,9 +160,14 @@ func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
ids[i] = p.ID
}
countMap := s.commentCountMap(ids)
replyMap := s.lastReplyMap(ids)
items := make([]PostListItem, len(posts))
for i, p := range posts {
items[i] = PostListItem{Post: p, CommentCount: countMap[p.ID]}
items[i] = PostListItem{
Post: p,
CommentCount: countMap[p.ID],
LastReplyAt: replyMap[p.ID],
}
}
return items, nil
}
@@ -260,6 +282,15 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
}
q.Keyword = kw
}
if q.UserID == 0 {
if author := strings.TrimSpace(q.Author); author != "" {
if uid, ok := resolveAuthorUserID(author); ok {
q.UserID = uid
} else {
return []model.Post{}, 0, nil
}
}
}
db := model.DB.Model(&model.Post{}).Preload("User").Preload("Board")
db = applyPostVisibility(db, q)
if q.BoardID > 0 {
@@ -270,7 +301,11 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
}
if q.Keyword != "" {
kw := "%" + q.Keyword + "%"
db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", kw, kw, kw)
if q.TitleOnly {
db = db.Where("title LIKE ?", kw)
} else {
db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", kw, kw, kw)
}
}
if tag := strings.TrimSpace(q.Tag); tag != "" {
// 整枚标签匹配:逗号/中文逗号分隔,忽略标签两侧空格,大小写不敏感
@@ -325,6 +360,22 @@ func escapeLikePattern(s string) string {
return s
}
// resolveAuthorUserID 按用户名精确匹配,否则按昵称精确匹配(优先用户名)
func resolveAuthorUserID(author string) (uint, bool) {
author = strings.TrimSpace(author)
if author == "" {
return 0, false
}
var u model.User
if err := model.DB.Select("id").Where("username = ?", author).First(&u).Error; err == nil {
return u.ID, true
}
if err := model.DB.Select("id").Where("nickname = ?", author).First(&u).Error; err == nil {
return u.ID, true
}
return 0, false
}
func (s *PostService) FindByID(id uint) (*model.Post, error) {
var post model.Post
err := model.DB.Preload("User").Preload("Board").First(&post, id).Error

View File

@@ -72,6 +72,57 @@ func (s *UserService) GetByUsername(username string) (*model.User, error) {
return &user, nil
}
// GetByEmail 按邮箱查询
func (s *UserService) GetByEmail(email string) (*model.User, error) {
email = NormalizeEmail(email)
var user model.User
if err := model.DB.Where("email = ?", email).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// ResetPasswordByEmail 通过邮箱重置密码(已通过验证码校验)
func (s *UserService) ResetPasswordByEmail(email, newPass string) error {
if err := ValidatePassword(newPass, s.settings.PasswordMinLen()); err != nil {
return err
}
user, err := s.GetByEmail(email)
if err != nil {
return errors.New("用户不存在")
}
hash, err := HashPassword(newPass)
if err != nil {
return err
}
return model.DB.Model(&model.User{}).Where("id = ?", user.ID).Update("password", hash).Error
}
// SearchUsersBrief 公开用户搜索(@补全):匹配用户名/昵称,不含邮箱
func (s *UserService) SearchUsersBrief(keyword string, limit int) ([]model.User, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return []model.User{}, nil
}
if limit <= 0 || limit > 20 {
limit = 8
}
like := "%" + keyword + "%"
var users []model.User
err := model.DB.Select("id", "username", "nickname", "avatar", "role", "verified").
Where("username LIKE ? OR nickname LIKE ?", like, like).
Order("username ASC").
Limit(limit).
Find(&users).Error
if err != nil {
return nil, err
}
if users == nil {
users = []model.User{}
}
return users, nil
}
// UpdateNickname 修改昵称
func (s *UserService) UpdateNickname(userID uint, nickname string) error {
nickname = strings.TrimSpace(nickname)