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

@ -1,8 +1,7 @@
package utils package utils
import ( import (
"fmt" "fmt"
"resty.dev/v3"
) )
type oidcUtils struct{} type oidcUtils struct{}
@ -11,61 +10,59 @@ 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", "client_id": clientID,
"client_id": clientID, "client_secret": clientSecret,
"client_secret": clientSecret, "code": code,
"code": code, "redirect_uri": redirectURI,
"redirect_uri": redirectURI, }).
}). SetHeader("Accept", "application/json").
SetHeader("Accept", "application/json"). SetResult(&TokenResponse{}).
SetResult(&TokenResponse{}). Post(tokenEndpoint)
Post(tokenEndpoint)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if tokenResp.StatusCode() != 200 { if tokenResp.StatusCode() != 200 {
return nil, fmt.Errorf("状态码: %d响应: %s", tokenResp.StatusCode(), tokenResp.String()) return nil, fmt.Errorf("状态码: %d响应: %s", tokenResp.StatusCode(), tokenResp.String())
} }
return tokenResp.Result().(*TokenResponse), nil return tokenResp.Result().(*TokenResponse), nil
} }
// 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"). SetResult(&UserInfo{}).
SetResult(&UserInfo{}). Get(userInfoEndpoint)
Get(userInfoEndpoint) if err != nil {
if err != nil { return nil, err
return nil, err }
}
if userInfoResp.StatusCode() != 200 { if userInfoResp.StatusCode() != 200 {
return nil, fmt.Errorf("状态码: %d响应: %s", userInfoResp.StatusCode(), userInfoResp.String()) return nil, fmt.Errorf("状态码: %d响应: %s", userInfoResp.StatusCode(), userInfoResp.String())
} }
return userInfoResp.Result().(*UserInfo), nil return userInfoResp.Result().(*UserInfo), nil
} }
type TokenResponse struct { type TokenResponse struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
TokenType string `json:"token_type"` TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"` ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token,omitempty"` IDToken string `json:"id_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"`
} }
// UserInfo 定义用户信息结构 // UserInfo 定义用户信息结构
type UserInfo struct { type UserInfo struct {
Sub string `json:"sub"` Sub string `json:"sub"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
Picture string `json:"picture,omitempty"` Picture string `json:"picture,omitempty"`
Groups []string `json:"groups,omitempty"` // 可选字段OIDC提供的用户组信息 Groups []string `json:"groups,omitempty"` // 可选字段OIDC提供的用户组信息
} }

View File

@ -1,61 +1,62 @@
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
BrowserVer string BrowserVer string
} }
// ParseUA 解析 UA返回结构化信息 // ParseUA 解析 UA返回结构化信息
func ParseUA(ua string) Result { func ParseUA(ua string) UAResult {
r := Result{} r := UAResult{}
// 1. 操作系统 + 版本
osRe := []*regexp.Regexp{
regexp.MustCompile(`\(Macintosh;.*Mac OS X ([0-9_]+)\)`),
regexp.MustCompile(`\(Windows NT ([0-9.]+)\)`),
regexp.MustCompile(`\(iPhone;.*OS ([0-9_]+)`),
regexp.MustCompile(`\(Android ([0-9.]+)`),
regexp.MustCompile(`\(X11;.*Linux `),
}
for _, re := range osRe {
if m := re.FindStringSubmatch(ua); len(m) > 1 {
switch {
case strings.Contains(m[0], "Macintosh"):
r.OS, r.OSVersion = "macOS", strings.Replace(m[1], "_", ".", -1)
case strings.Contains(m[0], "Windows NT"):
r.OS, r.OSVersion = "Windows", m[1]
case strings.Contains(m[0], "iPhone"):
r.OS, r.OSVersion = "iOS", strings.Replace(m[1], "_", ".", -1)
case strings.Contains(m[0], "Android"):
r.OS, r.OSVersion = "Android", m[1]
case strings.Contains(m[0], "Linux"):
r.OS = "Linux"
}
break
}
}
// 1. 操作系统 + 版本 // 2. 浏览器 + 版本(按优先级匹配)
osRe := []*regexp.Regexp{ browserRe := []struct {
regexp.MustCompile(`\(Macintosh;.*Mac OS X ([0-9_]+)\)`), re *regexp.Regexp
regexp.MustCompile(`\(Windows NT ([0-9.]+)\)`), name string
regexp.MustCompile(`\(iPhone;.*OS ([0-9_]+)`), }{
regexp.MustCompile(`\(Android ([0-9.]+)`), {regexp.MustCompile(`Edg/([\d.]+)`), "Edge"},
regexp.MustCompile(`\(X11;.*Linux `), {regexp.MustCompile(`Chrome/([\d.]+)`), "Chrome"},
} {regexp.MustCompile(`Firefox/([\d.]+)`), "Firefox"},
for _, re := range osRe { {regexp.MustCompile(`Version/([\d.]+).*Safari`), "Safari"},
if m := re.FindStringSubmatch(ua); len(m) > 1 { {regexp.MustCompile(`OPR/([\d.]+)`), "Opera"},
switch { }
case strings.Contains(m[0], "Macintosh"): for _, b := range browserRe {
r.OS, r.OSVersion = "macOS", strings.Replace(m[1], "_", ".", -1) if m := b.re.FindStringSubmatch(ua); len(m) > 1 {
case strings.Contains(m[0], "Windows NT"): r.Browser, r.BrowserVer = b.name, m[1]
r.OS, r.OSVersion = "Windows", m[1] break
case strings.Contains(m[0], "iPhone"): }
r.OS, r.OSVersion = "iOS", strings.Replace(m[1], "_", ".", -1) }
case strings.Contains(m[0], "Android"): return r
r.OS, r.OSVersion = "Android", m[1]
case strings.Contains(m[0], "Linux"):
r.OS = "Linux"
}
break
}
}
// 2. 浏览器 + 版本(按优先级匹配)
browserRe := []struct {
re *regexp.Regexp
name string
}{
{regexp.MustCompile(`Edg/([\d.]+)`), "Edge"},
{regexp.MustCompile(`Chrome/([\d.]+)`), "Chrome"},
{regexp.MustCompile(`Firefox/([\d.]+)`), "Firefox"},
{regexp.MustCompile(`Version/([\d.]+).*Safari`), "Safari"},
{regexp.MustCompile(`OPR/([\d.]+)`), "Opera"},
}
for _, b := range browserRe {
if m := b.re.FindStringSubmatch(ua); len(m) > 1 {
r.Browser, r.BrowserVer = b.name, m[1]
break
}
}
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,77 +191,84 @@ 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`} {/* 回复按钮 */}
> <button
<Heart className="w-3 h-3" /> <div>{likeCount}</div>
</button>
{/* 回复按钮 */}
<button
title={t("reply")} title={t("reply")}
onClick={() => { onClick={() => {
if (activeInput?.type === 'reply' && activeInput.id === comment.id) { if (activeInput?.type === 'reply' && activeInput.id === comment.id) {
setActiveInputId(null); setActiveInputId(null);
} else { } else {
setActiveInputId({ id: comment.id, type: 'reply' }); setActiveInputId({ id: comment.id, type: 'reply' });
} }
}} }}
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>
{/* 编辑和删除按钮 仅自己的评论可见 */} {/* 点赞按钮 */}
{user?.id === comment.user.id && ( <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 && (
<> <>
<button <button
title={t("edit")} title={t("edit")}
onClick={() => { onClick={() => {
if (activeInput?.type === 'edit' && activeInput.id === comment.id) { if (activeInput?.type === 'edit' && activeInput.id === comment.id) {
setActiveInputId(null); setActiveInputId(null);
} else { } else {
setActiveInputId({ id: comment.id, type: 'edit' }); setActiveInputId({ id: comment.id, type: 'edit' });
} }
}} }}
className={` className={`
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>
<button <button
title={t("delete")} title={t("delete")}
className={`flex items-center justify-center px-2 py-1 h-5 rounded className={`flex items-center justify-center px-2 py-1 h-5 rounded
text-primary-foreground dark:text-white text-xs text-primary-foreground dark:text-white text-xs
${confirming ? 'bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600' : 'bg-slate-400 hover:bg-slate-600 dark:hover:bg-slate-500'} fade-in`} ${confirming ? 'bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600' : 'bg-slate-400 hover:bg-slate-600 dark:hover:bg-slate-500'} fade-in`}
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> )}
)}
</button> </button>
</> </>
)} )}
{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
user={user} user={user}

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
} }