diff --git a/internal/dto/comment.go b/internal/dto/comment.go index 628b9a5..783fb33 100644 --- a/internal/dto/comment.go +++ b/internal/dto/comment.go @@ -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 { diff --git a/internal/model/comment.go b/internal/model/comment.go index 30649c1..83901d6 100644 --- a/internal/model/comment.go +++ b/internal/model/comment.go @@ -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 +} diff --git a/internal/service/comment.go b/internal/service/comment.go index 00479be..5b39575 100644 --- a/internal/service/comment.go +++ b/internal/service/comment.go @@ -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) } diff --git a/pkg/utils/client.go b/pkg/utils/client.go index d4b585b..ac2ef93 100644 --- a/pkg/utils/client.go +++ b/pkg/utils/client.go @@ -1 +1,5 @@ package utils + +import "resty.dev/v3" + +var client = resty.New() diff --git a/pkg/utils/ip_info.go b/pkg/utils/ip_info.go index d4b585b..6e3a506 100644 --- a/pkg/utils/ip_info.go +++ b/pkg/utils/ip_info.go @@ -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) +} diff --git a/pkg/utils/ip_info_test.go b/pkg/utils/ip_info_test.go index d4b585b..52fc1af 100644 --- a/pkg/utils/ip_info_test.go +++ b/pkg/utils/ip_info_test.go @@ -1 +1,10 @@ package utils + +import ( + "testing" +) + +func TestGetIPInfo(t *testing.T) { + r, err := GetIPInfo("1.1.1.1") + t.Log(r, err) +} diff --git a/pkg/utils/jwt.go b/pkg/utils/jwt.go index f5f0334..d223f50 100644 --- a/pkg/utils/jwt.go +++ b/pkg/utils/jwt.go @@ -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{} diff --git a/pkg/utils/oidc.go b/pkg/utils/oidc.go index 169c5dd..9b0f026 100644 --- a/pkg/utils/oidc.go +++ b/pkg/utils/oidc.go @@ -1,8 +1,7 @@ package utils import ( - "fmt" - "resty.dev/v3" + "fmt" ) type oidcUtils struct{} @@ -11,61 +10,59 @@ 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", - "client_id": clientID, - "client_secret": clientSecret, - "code": code, - "redirect_uri": redirectURI, - }). - SetHeader("Accept", "application/json"). - SetResult(&TokenResponse{}). - Post(tokenEndpoint) + tokenResp, err := client.R(). + SetFormData(map[string]string{ + "grant_type": "authorization_code", + "client_id": clientID, + "client_secret": clientSecret, + "code": code, + "redirect_uri": redirectURI, + }). + SetHeader("Accept", "application/json"). + SetResult(&TokenResponse{}). + Post(tokenEndpoint) - if err != nil { - return nil, err - } + if err != nil { + return nil, err + } - if tokenResp.StatusCode() != 200 { - return nil, fmt.Errorf("状态码: %d,响应: %s", tokenResp.StatusCode(), tokenResp.String()) - } - return tokenResp.Result().(*TokenResponse), nil + if tokenResp.StatusCode() != 200 { + return nil, fmt.Errorf("状态码: %d,响应: %s", tokenResp.StatusCode(), tokenResp.String()) + } + return tokenResp.Result().(*TokenResponse), nil } // 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"). - SetResult(&UserInfo{}). - Get(userInfoEndpoint) - if err != nil { - return nil, err - } + userInfoResp, err := client.R(). + SetHeader("Authorization", "Bearer "+accessToken). + SetHeader("Accept", "application/json"). + SetResult(&UserInfo{}). + Get(userInfoEndpoint) + if err != nil { + return nil, err + } - if userInfoResp.StatusCode() != 200 { - return nil, fmt.Errorf("状态码: %d,响应: %s", userInfoResp.StatusCode(), userInfoResp.String()) - } + if userInfoResp.StatusCode() != 200 { + return nil, fmt.Errorf("状态码: %d,响应: %s", userInfoResp.StatusCode(), userInfoResp.String()) + } - return userInfoResp.Result().(*UserInfo), nil + return userInfoResp.Result().(*UserInfo), nil } type TokenResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - IDToken string `json:"id_token,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + IDToken string `json:"id_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` } // UserInfo 定义用户信息结构 type UserInfo struct { - Sub string `json:"sub"` - Name string `json:"name"` - Email string `json:"email"` - Picture string `json:"picture,omitempty"` - Groups []string `json:"groups,omitempty"` // 可选字段,OIDC提供的用户组信息 + Sub string `json:"sub"` + Name string `json:"name"` + Email string `json:"email"` + Picture string `json:"picture,omitempty"` + Groups []string `json:"groups,omitempty"` // 可选字段,OIDC提供的用户组信息 } diff --git a/pkg/utils/ua.go b/pkg/utils/ua.go index 1d68e07..7123a61 100644 --- a/pkg/utils/ua.go +++ b/pkg/utils/ua.go @@ -1,61 +1,62 @@ package utils -import "regexp" +import ( + "regexp" + "strings" +) -type Result struct { - OS string - OSVersion string - Browser string - BrowserVer string +type UAResult struct { + OS string + OSVersion string + Browser string + BrowserVer string } // 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_]+)\)`), + 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. 操作系统 + 版本 - 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 - } - } - - // 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 + // 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 } diff --git a/pkg/utils/ua_test.go b/pkg/utils/ua_test.go index d4b585b..d512f0a 100644 --- a/pkg/utils/ua_test.go +++ b/pkg/utils/ua_test.go @@ -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) + } +} diff --git a/web/src/components/comment/comment-item.tsx b/web/src/components/comment/comment-item.tsx index 2803c83..044103c 100644 --- a/web/src/components/comment/comment-item.tsx +++ b/web/src/components/comment/comment-item.tsx @@ -158,7 +158,7 @@ export function CommentItem(
clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in w-12 h-12"> - +
@@ -181,7 +181,6 @@ export function CommentItem( }) })}}
-

{ isPrivate && @@ -192,77 +191,84 @@ export function CommentItem( } {comment.content}

-
- - {/* 点赞按钮 */} - - {/* 回复按钮 */} - - {/* 编辑和删除按钮 仅自己的评论可见 */} - {user?.id === comment.user.id && ( + + {/* 点赞按钮 */} + + + {/* 编辑和删除按钮 仅自己的评论可见 */} + {user?.id === comment.user.id && ( <> - )} + )} - {replyCount > 0 && + {replyCount > 0 && ( - } -
+ )} +
+
{/* 这俩输入框一次只能显示一个 */} {activeInput && activeInput.type === 'reply' && activeInput.id === comment.id &&