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"` // 点赞数量
IsLiked bool `json:"is_liked"` // 当前用户是否点赞
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 {

View File

@ -1,6 +1,7 @@
package model
import (
"github.com/snowykami/neo-blog/pkg/utils"
"gorm.io/gorm"
)
@ -16,6 +17,12 @@ type Comment struct {
IsPrivate bool `gorm:"default:false"` // 是否为私密评论,私密评论只有评论者和被评论对象所有者可见
RemoteAddr string `gorm:"type:text"` // 远程地址
UserAgent string `gorm:"type:text"`
Location string `gorm:"type:text"` // 用户位置基于IP
LikeCount 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"
"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/dto"
@ -122,6 +123,7 @@ func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dt
UpdatedAt: comment.UpdatedAt.String(),
User: comment.User.ToDto(),
}
// TODO: 返回更多字段
return &commentDto, err
}
@ -145,7 +147,7 @@ func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommen
if currentUserID != 0 {
isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment)
}
ua := utils.ParseUA(comment.UserAgent)
commentDto := dto.CommentDto{
ID: comment.ID,
Content: comment.Content,
@ -160,6 +162,9 @@ func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommen
LikeCount: comment.LikeCount,
IsLiked: isLiked,
IsPrivate: comment.IsPrivate,
OS: ua.OS + " " + ua.OSVersion,
Browser: ua.Browser + " " + ua.BrowserVer,
Location: comment.Location,
}
commentDtos = append(commentDtos, commentDto)
}

View File

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

View File

@ -1 +1,60 @@
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
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
import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/snowykami/neo-blog/pkg/constant"
"time"
)
type jwtUtils struct{}

View File

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

View File

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

View File

@ -1 +1,11 @@
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

@ -181,7 +181,6 @@ export function CommentItem(
})
})}</span>}
</div>
<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" />
@ -192,18 +191,14 @@ export function CommentItem(
}
{comment.content}
</p>
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-4 fade-in">
{/* 点赞按钮 */}
<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>
<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">
{/* 用户地理,浏览器,系统信息 */}
{comment.location && <span title={comment.location} >{comment.location}</span>}
{comment.browser && <span title={comment.browser}>{comment.browser}</span>}
{comment.os && <span title={comment.os}>{comment.os}</span>}
</div>
<div className="flex items-center gap-4 w-full md:w-auto">
{/* 回复按钮 */}
<button
title={t("reply")}
@ -216,9 +211,21 @@ export function CommentItem(
}}
className={`flex items-center justify-center px-2 py-1 h-5
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" />
</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 && (
<>
@ -235,7 +242,6 @@ export function CommentItem(
flex items-center justify-center px-2 py-1 h-5
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`}
>
<Pencil className="w-3 h-3" />
</button>
@ -248,7 +254,6 @@ export function CommentItem(
onClick={() => onClick(() => { onCommentDelete({ commentId: comment.id }); })}
onBlur={onBlur}
>
<Trash className="w-3 h-3" />
{confirming && (
<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">
{!showReplies ? t("expand_replies", { count: replyCount }) : t("collapse_replies")}
</button>
}
)}
</div>
</div>
{/* 这俩输入框一次只能显示一个 */}
{activeInput && activeInput.type === 'reply' && activeInput.id === comment.id && <CommentInput

View File

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