mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 11:06:23 +00:00
feat: 添加用户位置、操作系统和浏览器信息到评论功能
This commit is contained in:
@ -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 {
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -1 +1,5 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
|
import "resty.dev/v3"
|
||||||
|
|
||||||
|
var client = resty.New()
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
@ -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{}
|
||||||
|
@ -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").
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user