feat: SSR 个人闭环(profile / user / favorites)
登录用户可改资料与头像、浏览收藏;公开主页不含邮箱;改密吊销并重建本端 session。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
69
routers/web/favorites.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
168
routers/web/profile.go
Normal 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
106
routers/web/user.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user