feat: 添加用户位置、操作系统和浏览器信息到评论功能

This commit is contained in:
2025-09-13 15:08:46 +08:00
parent 44f15e1ff1
commit 011dc298c2
12 changed files with 253 additions and 148 deletions

View File

@ -14,6 +14,9 @@ type CommentDto struct {
LikeCount uint64 `json:"like_count"` // 点赞数量 LikeCount uint64 `json:"like_count"` // 点赞数量
IsLiked bool `json:"is_liked"` // 当前用户是否点赞 IsLiked bool `json:"is_liked"` // 当前用户是否点赞
IsPrivate bool `json:"is_private"` IsPrivate bool `json:"is_private"`
Location string `json:"location"` // 用户位置基于IP
OS string `json:"os"` // 用户操作系统基于User-Agent
Browser string `json:"browser"` // 用户浏览器基于User-Agent
} }
type CreateCommentReq struct { type CreateCommentReq struct {

View File

@ -1,6 +1,7 @@
package model package model
import ( import (
"github.com/snowykami/neo-blog/pkg/utils"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -16,6 +17,12 @@ type Comment struct {
IsPrivate bool `gorm:"default:false"` // 是否为私密评论,私密评论只有评论者和被评论对象所有者可见 IsPrivate bool `gorm:"default:false"` // 是否为私密评论,私密评论只有评论者和被评论对象所有者可见
RemoteAddr string `gorm:"type:text"` // 远程地址 RemoteAddr string `gorm:"type:text"` // 远程地址
UserAgent string `gorm:"type:text"` UserAgent string `gorm:"type:text"`
Location string `gorm:"type:text"` // 用户位置基于IP
LikeCount uint64 LikeCount uint64
CommentCount uint64 CommentCount uint64
} }
func (c *Comment) AfterCreate(tx *gorm.DB) (err error) {
// 更新评论IP
return tx.Model(c).Update("Location", utils.GetLocationString(c.RemoteAddr)).Error
}

View File

@ -5,6 +5,7 @@ import (
"strconv" "strconv"
"github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/utils"
"github.com/snowykami/neo-blog/internal/ctxutils" "github.com/snowykami/neo-blog/internal/ctxutils"
"github.com/snowykami/neo-blog/internal/dto" "github.com/snowykami/neo-blog/internal/dto"
@ -122,6 +123,7 @@ func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dt
UpdatedAt: comment.UpdatedAt.String(), UpdatedAt: comment.UpdatedAt.String(),
User: comment.User.ToDto(), User: comment.User.ToDto(),
} }
// TODO: 返回更多字段
return &commentDto, err return &commentDto, err
} }
@ -145,7 +147,7 @@ func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommen
if currentUserID != 0 { if currentUserID != 0 {
isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment) isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment)
} }
ua := utils.ParseUA(comment.UserAgent)
commentDto := dto.CommentDto{ commentDto := dto.CommentDto{
ID: comment.ID, ID: comment.ID,
Content: comment.Content, Content: comment.Content,
@ -160,6 +162,9 @@ func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommen
LikeCount: comment.LikeCount, LikeCount: comment.LikeCount,
IsLiked: isLiked, IsLiked: isLiked,
IsPrivate: comment.IsPrivate, IsPrivate: comment.IsPrivate,
OS: ua.OS + " " + ua.OSVersion,
Browser: ua.Browser + " " + ua.BrowserVer,
Location: comment.Location,
} }
commentDtos = append(commentDtos, commentDto) commentDtos = append(commentDtos, commentDto)
} }

View File

@ -1 +1,5 @@
package utils package utils
import "resty.dev/v3"
var client = resty.New()

View File

@ -1 +1,60 @@
package utils package utils
import (
"fmt"
"github.com/sirupsen/logrus"
)
type IPData struct {
IP string `json:"ip"`
Dec string `json:"dec"`
Country string `json:"country"`
CountryCode string `json:"countryCode"`
Province string `json:"province"`
City string `json:"city"`
Districts string `json:"districts"`
IDC string `json:"idc"`
ISP string `json:"isp"`
Net string `json:"net"`
Zipcode string `json:"zipcode"`
Areacode string `json:"areacode"`
Protocol string `json:"protocol"`
Location string `json:"location"`
MyIP string `json:"myip"`
Time string `json:"time"`
}
type IPInfoResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data *IPData `json:"data"`
}
func GetIPInfo(ip string) (*IPData, error) {
// https://api.mir6.com/api/ip?ip={ip}&type=json
ipInfoResponse := &IPInfoResponse{}
logrus.Info(fmt.Sprintf("https://api.mir6.com/api/ip?ip=%s&type=json", ip))
resp, err := client.R().
SetResult(ipInfoResponse).
Get(fmt.Sprintf("https://api.mir6.com/api/ip?ip=%s&type=json", ip))
if err != nil {
return nil, err
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("状态码: %d响应: %s", resp.StatusCode(), resp.String())
}
return ipInfoResponse.Data, nil
}
func GetLocationString(ip string) string {
ipInfo, err := GetIPInfo(ip)
if err != nil {
logrus.Error(err)
return ""
}
if ipInfo == nil {
return ""
}
return fmt.Sprintf("%s %s %s %s", ipInfo.Country, ipInfo.Province, ipInfo.City, ipInfo.ISP)
}

View File

@ -1 +1,10 @@
package utils package utils
import (
"testing"
)
func TestGetIPInfo(t *testing.T) {
r, err := GetIPInfo("1.1.1.1")
t.Log(r, err)
}

View File

@ -1,9 +1,10 @@
package utils package utils
import ( import (
"time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/constant"
"time"
) )
type jwtUtils struct{} type jwtUtils struct{}

View File

@ -2,7 +2,6 @@ package utils
import ( import (
"fmt" "fmt"
"resty.dev/v3"
) )
type oidcUtils struct{} type oidcUtils struct{}
@ -11,7 +10,6 @@ var Oidc = oidcUtils{}
// RequestToken 请求访问令牌 // RequestToken 请求访问令牌
func (u *oidcUtils) RequestToken(tokenEndpoint, clientID, clientSecret, code, redirectURI string) (*TokenResponse, error) { func (u *oidcUtils) RequestToken(tokenEndpoint, clientID, clientSecret, code, redirectURI string) (*TokenResponse, error) {
client := resty.New()
tokenResp, err := client.R(). tokenResp, err := client.R().
SetFormData(map[string]string{ SetFormData(map[string]string{
"grant_type": "authorization_code", "grant_type": "authorization_code",
@ -36,7 +34,6 @@ func (u *oidcUtils) RequestToken(tokenEndpoint, clientID, clientSecret, code, re
// RequestUserInfo 请求用户信息 // RequestUserInfo 请求用户信息
func (u *oidcUtils) RequestUserInfo(userInfoEndpoint, accessToken string) (*UserInfo, error) { func (u *oidcUtils) RequestUserInfo(userInfoEndpoint, accessToken string) (*UserInfo, error) {
client := resty.New()
userInfoResp, err := client.R(). userInfoResp, err := client.R().
SetHeader("Authorization", "Bearer "+accessToken). SetHeader("Authorization", "Bearer "+accessToken).
SetHeader("Accept", "application/json"). SetHeader("Accept", "application/json").

View File

@ -1,8 +1,11 @@
package utils package utils
import "regexp" import (
"regexp"
"strings"
)
type Result struct { type UAResult struct {
OS string OS string
OSVersion string OSVersion string
Browser string Browser string
@ -10,9 +13,8 @@ type Result struct {
} }
// ParseUA 解析 UA返回结构化信息 // ParseUA 解析 UA返回结构化信息
func ParseUA(ua string) Result { func ParseUA(ua string) UAResult {
r := Result{} r := UAResult{}
// 1. 操作系统 + 版本 // 1. 操作系统 + 版本
osRe := []*regexp.Regexp{ osRe := []*regexp.Regexp{
regexp.MustCompile(`\(Macintosh;.*Mac OS X ([0-9_]+)\)`), regexp.MustCompile(`\(Macintosh;.*Mac OS X ([0-9_]+)\)`),
@ -56,6 +58,5 @@ func ParseUA(ua string) Result {
break break
} }
} }
return r return r
} }

View File

@ -1 +1,11 @@
package utils package utils
import "testing"
func TestParseUA(t *testing.T) {
ua := "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0"
result := ParseUA(ua)
if result.OS != "macOS" || result.OSVersion != "10.15.7" || result.Browser != "Edge" || result.BrowserVer != "140.0.0.0" {
t.Errorf("ParseUA failed, got %+v", result)
}
}

View File

@ -158,7 +158,7 @@ export function CommentItem(
<div> <div>
<div className="flex"> <div className="flex">
<div onClick={() => clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in w-12 h-12"> <div onClick={() => clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in w-12 h-12">
<GravatarAvatar className="w-full h-full" url={comment.user.avatarUrl} email={comment.user.email} size={100}/> <GravatarAvatar className="w-full h-full" url={comment.user.avatarUrl} email={comment.user.email} size={100} />
</div> </div>
<div className="flex-1 pl-2 fade-in-up"> <div className="flex-1 pl-2 fade-in-up">
<div className="flex gap-2 md:gap-4 items-center"> <div className="flex gap-2 md:gap-4 items-center">
@ -181,7 +181,6 @@ export function CommentItem(
}) })
})}</span>} })}</span>}
</div> </div>
<p className="text-lg text-slate-600 dark:text-slate-400 fade-in"> <p className="text-lg text-slate-600 dark:text-slate-400 fade-in">
{ {
isPrivate && <Lock className="inline w-4 h-4 mr-1 mb-1 text-slate-500 dark:text-slate-400" /> isPrivate && <Lock className="inline w-4 h-4 mr-1 mb-1 text-slate-500 dark:text-slate-400" />
@ -192,18 +191,14 @@ export function CommentItem(
} }
{comment.content} {comment.content}
</p> </p>
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-4 fade-in"> <div className="mt-1 text-xs text-slate-500 dark:text-slate-400 fade-in flex flex-col md:flex-row items-start md:items-center md:justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
{/* 点赞按钮 */} {/* 用户地理,浏览器,系统信息 */}
<button {comment.location && <span title={comment.location} >{comment.location}</span>}
title={t(liked ? "unlike" : "like")} {comment.browser && <span title={comment.browser}>{comment.browser}</span>}
onClick={handleToggleLike} {comment.os && <span title={comment.os}>{comment.os}</span>}
className={`flex items-center justify-center px-2 py-1 h-5 gap-1 text-xs rounded </div>
${liked ? 'bg-primary ' : 'bg-slate-400 hover:bg-slate-600'} <div className="flex items-center gap-4 w-full md:w-auto">
text-primary-foreground dark:text-white dark:hover:bg-slate-500 fade-in`}
>
<Heart className="w-3 h-3" /> <div>{likeCount}</div>
</button>
{/* 回复按钮 */} {/* 回复按钮 */}
<button <button
title={t("reply")} title={t("reply")}
@ -216,9 +211,21 @@ export function CommentItem(
}} }}
className={`flex items-center justify-center px-2 py-1 h-5 className={`flex items-center justify-center px-2 py-1 h-5
text-primary-foreground dark:text-white text-xs text-primary-foreground dark:text-white text-xs
rounded ${activeInput?.type === 'reply' && activeInput.id === comment.id ? "bg-slate-600" : "bg-slate-400"} hover:bg-slate-600 dark:hover:bg-slate-500 fade-in-up`}> rounded ${activeInput?.type === 'reply' && activeInput.id === comment.id ? "bg-slate-600" : "bg-slate-400"} hover:bg-slate-600 dark:hover:bg-slate-500 fade-in-up`}
>
<Reply className="w-3 h-3" /> <Reply className="w-3 h-3" />
</button> </button>
{/* 点赞按钮 */}
<button
title={t(liked ? "unlike" : "like")}
onClick={handleToggleLike}
className={`flex items-center justify-center px-2 py-1 h-5 gap-1 text-xs rounded
${liked ? 'bg-primary' : 'bg-slate-400 hover:bg-slate-600'}
text-primary-foreground dark:text-white dark:hover:bg-slate-500 fade-in`}
>
<Heart className="w-3 h-3" /> <div>{likeCount}</div>
</button>
{/* 编辑和删除按钮 仅自己的评论可见 */} {/* 编辑和删除按钮 仅自己的评论可见 */}
{user?.id === comment.user.id && ( {user?.id === comment.user.id && (
<> <>
@ -235,7 +242,6 @@ export function CommentItem(
flex items-center justify-center px-2 py-1 h-5 flex items-center justify-center px-2 py-1 h-5
text-primary-foreground dark:text-white text-xs text-primary-foreground dark:text-white text-xs
rounded ${activeInput?.type === 'edit' && activeInput.id === comment.id ? "bg-slate-600" : "bg-slate-400"} hover:bg-slate-600 dark:hover:bg-slate-500 fade-in-up`} rounded ${activeInput?.type === 'edit' && activeInput.id === comment.id ? "bg-slate-600" : "bg-slate-400"} hover:bg-slate-600 dark:hover:bg-slate-500 fade-in-up`}
> >
<Pencil className="w-3 h-3" /> <Pencil className="w-3 h-3" />
</button> </button>
@ -248,7 +254,6 @@ export function CommentItem(
onClick={() => onClick(() => { onCommentDelete({ commentId: comment.id }); })} onClick={() => onClick(() => { onCommentDelete({ commentId: comment.id }); })}
onBlur={onBlur} onBlur={onBlur}
> >
<Trash className="w-3 h-3" /> <Trash className="w-3 h-3" />
{confirming && ( {confirming && (
<span className="ml-1 confirm-delete-anim">{t("confirm_delete")}</span> <span className="ml-1 confirm-delete-anim">{t("confirm_delete")}</span>
@ -257,11 +262,12 @@ export function CommentItem(
</> </>
)} )}
{replyCount > 0 && {replyCount > 0 && (
<button onClick={toggleReplies} className="fade-in-up"> <button onClick={toggleReplies} className="fade-in-up">
{!showReplies ? t("expand_replies", { count: replyCount }) : t("collapse_replies")} {!showReplies ? t("expand_replies", { count: replyCount }) : t("collapse_replies")}
</button> </button>
} )}
</div>
</div> </div>
{/* 这俩输入框一次只能显示一个 */} {/* 这俩输入框一次只能显示一个 */}
{activeInput && activeInput.type === 'reply' && activeInput.id === comment.id && <CommentInput {activeInput && activeInput.type === 'reply' && activeInput.id === comment.id && <CommentInput

View File

@ -15,4 +15,7 @@ export interface Comment {
replyCount: number replyCount: number
likeCount: number likeCount: number
isLiked: boolean isLiked: boolean
os: string
browser: string
location: string
} }