增加 @提及、搜索筛选与找回密码,并将右栏热门改为正在聊。
补齐评论提及补全与通知、按作者/板块/仅标题搜索,以及邮箱验证码重置密码;同时修复 Go 提及正则并优化侧栏悬停样式。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
65
service/mention.go
Normal 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
14
service/mention_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user