feat: SSR 个人闭环(profile / user / favorites)

登录用户可改资料与头像、浏览收藏;公开主页不含邮箱;改密吊销并重建本端 session。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 05:53:56 +08:00
parent fde5f628ec
commit 126e3bae98
19 changed files with 599 additions and 20 deletions

View File

@@ -96,7 +96,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
webpages.Register(r, webpages.Deps{
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
Settings: settingsSvc, Auth: authSvc,
Settings: settingsSvc, Auth: authSvc, User: userSvc,
Board: boardSvc, Post: postSvc, Comment: commentSvc,
Message: messageSvc, Filter: filter,
Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore,

View File

@@ -14,6 +14,7 @@ type Deps struct {
JWTSecret string
Settings *services.ForumSettingsService
Auth *services.AuthService
User *services.UserService
Board *services.BoardService
Post *services.PostService
Comment *services.CommentService

69
routers/web/favorites.go Normal file
View File

@@ -0,0 +1,69 @@
package web
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
type favItem struct {
PostID uint
Title string
AuthorName string
BoardName string
CreatedLabel string
}
type favoritesData struct {
PageChrome
Items []favItem
Page int
PrevPage int
NextPage int
HasPrev bool
HasMore bool
Total int64
}
// FavoritesGet 我的收藏
func (d Deps) FavoritesGet(c *gin.Context) {
ctx := d.ctx(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
size := d.Settings.PageSizeDefault()
favs, total, err := d.Post.ListFavorites(ctx.UserID(), page, size)
if err != nil {
chrome := d.chrome(ctx, "收藏 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = err.Error()
ctx.HTML(http.StatusOK, "favorites/list", favoritesData{PageChrome: chrome})
return
}
items := make([]favItem, 0, len(favs))
for _, f := range favs {
title := f.Post.Title
author := strings.TrimSpace(f.Post.User.Nickname)
if author == "" {
author = f.Post.User.Username
}
board := ""
if f.Post.Board.ID > 0 {
board = f.Post.Board.Name
}
items = append(items, favItem{
PostID: f.PostID, Title: title, AuthorName: author, BoardName: board,
CreatedLabel: formatTime(f.CreatedAt),
})
}
chrome := d.chrome(ctx, "收藏 · "+d.Settings.SiteBranding().Name, "", "")
hasMore := int64(page*size) < total
ctx.HTML(http.StatusOK, "favorites/list", favoritesData{
PageChrome: chrome,
Items: items,
Page: page, PrevPage: page - 1, NextPage: page + 1,
HasPrev: page > 1, HasMore: hasMore, Total: total,
})
}

View File

@@ -52,9 +52,14 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost)
}
g.GET("/profile", deps.PendingPage)
g.GET("/user/:id", deps.UserPublic)
g.GET("/profile", authMW.RequireAuth(), deps.ProfileGet)
g.POST("/profile/nickname", authMW.RequireAuth(), deps.ProfileNicknamePost)
g.POST("/profile/signature", authMW.RequireAuth(), deps.ProfileSignaturePost)
g.POST("/profile/password", authMW.RequireAuth(), deps.ProfilePasswordPost)
g.POST("/profile/avatar", authMW.RequireAuth(), deps.ProfileAvatarPost)
g.GET("/favorites", authMW.RequireAuth(), deps.FavoritesGet)
g.GET("/messages", deps.PendingPage)
g.GET("/favorites", deps.PendingPage)
g.GET("/projects", deps.PendingPage)
g.GET("/links", deps.PendingPage)
g.GET("/boards", deps.PendingPage)

View File

@@ -20,6 +20,7 @@ type PostPageData struct {
PostPath string
PostTitle string
AuthorName string
AuthorID uint
BoardID uint
BoardName string
Pinned bool
@@ -41,6 +42,7 @@ type PostPageData struct {
type CommentView struct {
Floor int
AuthorName string
AuthorID uint
CreatedLabel string
Content string
ContentHidden bool
@@ -75,7 +77,8 @@ func (d Deps) PostView(c *gin.Context) {
an = cm.User.Username
}
cv = append(cv, CommentView{
Floor: cm.Floor, AuthorName: an, CreatedLabel: formatTime(cm.CreatedAt),
Floor: cm.Floor, AuthorName: an, AuthorID: cm.UserID,
CreatedLabel: formatTime(cm.CreatedAt),
Content: cm.Content, ContentHidden: cm.ContentHidden,
})
}
@@ -95,7 +98,8 @@ func (d Deps) PostView(c *gin.Context) {
ctx.HTML(http.StatusOK, "post", PostPageData{
PageChrome: chrome, PostID: post.ID,
PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)),
PostTitle: post.Title, AuthorName: author, BoardID: post.BoardID, BoardName: boardName,
PostTitle: post.Title, AuthorName: author, AuthorID: post.UserID,
BoardID: post.BoardID, BoardName: boardName,
Pinned: post.Pinned || post.BoardPinned, Featured: post.Featured,
PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt),
ViewCount: post.ViewCount, LikeCount: post.LikeCount,

168
routers/web/profile.go Normal file
View File

@@ -0,0 +1,168 @@
package web
import (
"fmt"
"net/http"
"strings"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
type profileData struct {
PageChrome
UserID uint
Username string
Nickname string
Signature string
Avatar string
Email string
Level int
Exp int
Points int
PostCount int64
CommentCount int64
FavoriteCount int64
LikeReceived int64
PublicURL string
AvatarMaxMB int
SignatureMax int
}
// ProfileGet 个人中心
func (d Deps) ProfileGet(c *gin.Context) {
ctx := d.ctx(c)
d.renderProfile(ctx, "")
}
func (d Deps) renderProfile(ctx *webctx.Context, errMsg string) {
uid := ctx.UserID()
user, err := d.User.GetByID(uid)
if err != nil {
ctx.SetFlash("用户不存在")
ctx.Redirect("/")
return
}
st, _ := d.User.ActivityStats(uid)
chrome := d.chrome(ctx, "个人中心 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = errMsg
nick := strings.TrimSpace(user.Nickname)
if nick == "" {
nick = user.Username
}
data := profileData{
PageChrome: chrome,
UserID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Signature: user.Signature,
Avatar: user.Avatar,
Email: user.Email,
Level: models.LevelFromExp(user.Exp),
Exp: user.Exp,
Points: user.Points,
PostCount: st.PostCount,
CommentCount: st.CommentCount,
FavoriteCount: st.FavoriteCount,
LikeReceived: st.LikeReceived,
PublicURL: fmt.Sprintf("/user/%d", user.ID),
AvatarMaxMB: d.Settings.AvatarMaxMB(),
SignatureMax: d.Settings.SignatureMax(),
}
// 导航显示最新昵称
data.ViewerName = nick
ctx.HTML(http.StatusOK, "profile/view", data)
}
// ProfileNicknamePost 改昵称
func (d Deps) ProfileNicknamePost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderProfile(ctx, "无效请求,请重试")
return
}
if err := d.User.UpdateNickname(ctx.UserID(), strings.TrimSpace(c.PostForm("nickname"))); err != nil {
d.renderProfile(ctx, err.Error())
return
}
ctx.SetFlash("昵称已更新")
ctx.Redirect("/profile")
}
// ProfileSignaturePost 改签名
func (d Deps) ProfileSignaturePost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderProfile(ctx, "无效请求,请重试")
return
}
if err := d.User.UpdateSignature(ctx.UserID(), c.PostForm("signature")); err != nil {
d.renderProfile(ctx, err.Error())
return
}
ctx.SetFlash("签名已更新")
ctx.Redirect("/profile")
}
// ProfilePasswordPost 改密码:吊销全部 session 后为本端重建
func (d Deps) ProfilePasswordPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderProfile(ctx, "无效请求,请重试")
return
}
oldPass := c.PostForm("old_password")
newPass := c.PostForm("new_password")
confirm := c.PostForm("new_password2")
if newPass != confirm {
d.renderProfile(ctx, "两次输入的新密码不一致")
return
}
uid := ctx.UserID()
if err := d.User.UpdatePassword(uid, oldPass, newPass); err != nil {
d.renderProfile(ctx, err.Error())
return
}
services.RevokeUserSessions(uid)
user, err := d.User.GetByID(uid)
if err != nil {
ctx.SetFlash("密码已更新,请重新登录")
ctx.Redirect("/login?redirect=/profile")
return
}
sid, err := d.Auth.CreateSessionForUser(user, c.ClientIP(), c.Request.UserAgent())
if err != nil {
ctx.SetFlash("密码已更新,请重新登录")
ctx.Redirect("/login?redirect=/profile")
return
}
ctx.SetLoginCookie(sid)
ctx.SetFlash("密码已更新,其它设备已登出")
ctx.Redirect("/profile")
}
// ProfileAvatarPost 上传头像
func (d Deps) ProfileAvatarPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderProfile(ctx, "无效请求,请重试")
return
}
file, err := c.FormFile("avatar")
if err != nil {
d.renderProfile(ctx, "请选择头像文件")
return
}
if d.Store == nil {
d.renderProfile(ctx, "上传存储未就绪")
return
}
if _, err := d.User.UploadAvatar(ctx.UserID(), file, d.Store); err != nil {
d.renderProfile(ctx, err.Error())
return
}
ctx.SetFlash("头像已更新")
ctx.Redirect("/profile")
}

106
routers/web/user.go Normal file
View File

@@ -0,0 +1,106 @@
package web
import (
"net/http"
"strconv"
"strings"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
type userPublicData struct {
PageChrome
UserID uint
Username string
Nickname string
Signature string
Avatar string
Level int
Exp int
Points int
PostCount int64
CommentCount int64
FavoriteCount int64
LikeReceived int64
IsSelf bool
Posts []PostListItem
Page int
PrevPage int
NextPage int
HasPrev bool
HasMore bool
}
// UserPublic 公开用户主页(不含邮箱)
func (d Deps) UserPublic(c *gin.Context) {
ctx := d.ctx(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
d.render404(ctx)
return
}
user, err := d.User.GetByID(uint(id))
if err != nil {
d.render404(ctx)
return
}
st, _ := d.User.ActivityStats(user.ID)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
size := d.Settings.PageSizeDefault()
posts, total, _ := d.Post.List(services.PostListQuery{
UserID: user.ID,
Page: page,
Size: size,
Sort: "latest",
ViewerID: ctx.UserID(),
ViewerIsAdmin: ctx.IsAdmin(),
})
items := make([]PostListItem, 0, len(posts))
for _, p := range posts {
board := ""
if p.Board.ID > 0 {
board = p.Board.Name
}
items = append(items, PostListItem{
ID: p.ID, Title: p.Title, AuthorName: displayName(user),
BoardName: board, Pinned: p.Pinned || p.BoardPinned, Featured: p.Featured,
CreatedLabel: formatTime(p.CreatedAt),
})
}
nick := displayName(user)
chrome := d.chrome(ctx, nick+" · "+d.Settings.SiteBranding().Name, strings.TrimSpace(user.Signature), "")
hasMore := int64(page*size) < total
ctx.HTML(http.StatusOK, "user/view", userPublicData{
PageChrome: chrome,
UserID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Signature: user.Signature,
Avatar: user.Avatar,
Level: models.LevelFromExp(user.Exp),
Exp: user.Exp,
Points: user.Points,
PostCount: st.PostCount, CommentCount: st.CommentCount,
FavoriteCount: st.FavoriteCount, LikeReceived: st.LikeReceived,
IsSelf: ctx.UserID() == user.ID,
Posts: items,
Page: page, PrevPage: page - 1, NextPage: page + 1,
HasPrev: page > 1, HasMore: hasMore,
})
}
func displayName(u *models.User) string {
if u == nil {
return ""
}
n := strings.TrimSpace(u.Nickname)
if n == "" {
return u.Username
}
return n
}