2026-07-17 16:55:22 +08:00

381 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package userapi
import (
"net/http"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
const (
profileVisitorPageMax int32 = 100
profileVisitorPageSizeMax int32 = 100
)
type profileVisitRecordData struct {
VisitorUserID string `json:"visitor_user_id,omitempty"`
AnonymousVisitID string `json:"anonymous_visit_id,omitempty"`
VisitorIdentityHidden bool `json:"visitor_identity_hidden"`
TargetUserID string `json:"target_user_id"`
VisitCount int64 `json:"visit_count"`
LastVisitedAtMS int64 `json:"last_visited_at_ms"`
}
type followRecordData struct {
FollowerUserID string `json:"follower_user_id"`
FolloweeUserID string `json:"followee_user_id"`
FollowedAtMS int64 `json:"followed_at_ms"`
}
type friendRecordData struct {
UserID string `json:"user_id"`
FriendUserID string `json:"friend_user_id"`
FriendedAtMS int64 `json:"friended_at_ms"`
}
type friendApplicationData struct {
RequesterUserID string `json:"requester_user_id"`
TargetUserID string `json:"target_user_id"`
Status string `json:"status"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
// listMyProfileVisitors 返回访问我的主页的人;这是关系 read model不实时扫用户行为日志。
func (h *Handler) listMyProfileVisitors(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page, pageSize, ok := socialPage(request)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if page > profileVisitorPageMax {
// 当前 page 协议需要从公开、匿名两个有序分支读取到目标 offset 后再合并;限制页码可把
// 单次读取窗口硬限制在 10,000 条,避免客户端构造高页把 user-service 内存变成无界缓存。
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "page must be less than or equal to 100")
return
}
if pageSize > profileVisitorPageSizeMax {
// 访客列表的页宽是对外契约;网关必须在进入 gRPC 前拒绝超限请求,
// 不依赖 user-service 静默截断,否则 Flutter 无法区分参数错误与服务端分页。
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "page_size must be less than or equal to 100")
return
}
resp, err := h.userSocialClient.ListProfileVisitors(request.Context(), &userv1.ListProfileVisitorsRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Page: page,
PageSize: pageSize,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
records := make([]profileVisitRecordData, 0, len(resp.GetRecords()))
for _, record := range resp.GetRecords() {
records = append(records, profileVisitData(record))
}
httpkit.WriteOK(writer, request, map[string]any{"records": records, "total": resp.GetTotal()})
}
// listMyFollowing 返回我关注的用户列表;关注我的列表后续单独建接口,避免语义混淆。
func (h *Handler) listMyFollowing(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page, pageSize, ok := socialPage(request)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.userSocialClient.ListFollowing(request.Context(), &userv1.ListFollowingRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Page: page,
PageSize: pageSize,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
records := make([]followRecordData, 0, len(resp.GetRecords()))
for _, record := range resp.GetRecords() {
records = append(records, followData(record))
}
httpkit.WriteOK(writer, request, map[string]any{"records": records, "total": resp.GetTotal()})
}
// listMyFriends 返回双向好友列表。
func (h *Handler) listMyFriends(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page, pageSize, ok := socialPage(request)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.userSocialClient.ListFriends(request.Context(), &userv1.ListFriendsRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Page: page,
PageSize: pageSize,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
records := make([]friendRecordData, 0, len(resp.GetRecords()))
for _, record := range resp.GetRecords() {
records = append(records, friendData(record))
}
httpkit.WriteOK(writer, request, map[string]any{"records": records, "total": resp.GetTotal()})
}
// listMyFriendApplications 返回待处理申请direction=incoming/outgoing。
func (h *Handler) listMyFriendApplications(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page, pageSize, ok := socialPage(request)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.userSocialClient.ListFriendApplications(request.Context(), &userv1.ListFriendApplicationsRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Direction: strings.TrimSpace(request.URL.Query().Get("direction")),
Page: page,
PageSize: pageSize,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
applications := make([]friendApplicationData, 0, len(resp.GetApplications()))
for _, application := range resp.GetApplications() {
applications = append(applications, friendApplicationDataFromProto(application))
}
httpkit.WriteOK(writer, request, map[string]any{"applications": applications, "total": resp.GetTotal()})
}
// userSocialAction 处理 /users/{user_id}/visit|follow|friend-requests|friend-requests/accept|friend。
func (h *Handler) userSocialAction(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
targetUserID, ok := httpkit.PositiveInt64PathValue(request, "user_id")
if !ok {
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
action := strings.Trim(request.PathValue("action"), "/")
switch action {
case "visit":
h.recordProfileVisit(writer, request, targetUserID)
case "follow":
h.handleFollowAction(writer, request, targetUserID)
case "friend-requests":
h.applyFriend(writer, request, targetUserID)
case "friend-requests/accept":
h.acceptFriendApplication(writer, request, targetUserID)
case "friend":
h.deleteFriend(writer, request, targetUserID)
default:
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
}
}
func (h *Handler) recordProfileVisit(writer http.ResponseWriter, request *http.Request, targetUserID int64) {
if request.Method != http.MethodPost {
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
resp, err := h.userSocialClient.RecordProfileVisit(request.Context(), &userv1.RecordProfileVisitRequest{
Meta: httpkit.UserMeta(request, ""),
VisitorUserId: auth.UserIDFromContext(request.Context()),
TargetUserId: targetUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
data := map[string]any{
"recorded": resp.GetRecorded(),
"target_stats_hidden": resp.GetTargetStatsHidden(),
}
if !resp.GetTargetStatsHidden() {
// 隐藏标志为 true 时不写 target_stats key不能把 nil 转成零值 DTO否则客户端会把伪造的 0 当成真实统计。
data["target_stats"] = overviewStatsFromProto(resp.GetTargetStats())
}
if h.userProfileClient != nil {
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: targetUserID,
})
if err == nil {
// 访问记录归 social read model资料卡展示归 user-service 用户主资料;这里仅拼装 App 打开别人资料卡所需的当前快照。
data["target_profile"] = profileData(profileResp.GetUser(), 0)
}
}
httpkit.WriteOK(writer, request, data)
}
func (h *Handler) handleFollowAction(writer http.ResponseWriter, request *http.Request, followeeUserID int64) {
userID := auth.UserIDFromContext(request.Context())
switch request.Method {
case http.MethodPost:
resp, err := h.userSocialClient.FollowUser(request.Context(), &userv1.FollowUserRequest{
Meta: httpkit.UserMeta(request, ""),
FollowerUserId: userID,
FolloweeUserId: followeeUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{"following": resp.GetFollowing(), "follower_stats": overviewStatsFromProto(resp.GetFollowerStats())})
case http.MethodDelete:
resp, err := h.userSocialClient.UnfollowUser(request.Context(), &userv1.UnfollowUserRequest{
Meta: httpkit.UserMeta(request, ""),
FollowerUserId: userID,
FolloweeUserId: followeeUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{"following": resp.GetFollowing(), "follower_stats": overviewStatsFromProto(resp.GetFollowerStats())})
default:
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
}
}
func (h *Handler) applyFriend(writer http.ResponseWriter, request *http.Request, targetUserID int64) {
if request.Method != http.MethodPost {
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
resp, err := h.userSocialClient.ApplyFriend(request.Context(), &userv1.ApplyFriendRequest{
Meta: httpkit.UserMeta(request, ""),
RequesterUserId: auth.UserIDFromContext(request.Context()),
TargetUserId: targetUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{"application": friendApplicationDataFromProto(resp.GetApplication()), "already_friends": resp.GetAlreadyFriends()})
}
func (h *Handler) acceptFriendApplication(writer http.ResponseWriter, request *http.Request, requesterUserID int64) {
if request.Method != http.MethodPost {
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
resp, err := h.userSocialClient.AcceptFriendApplication(request.Context(), &userv1.AcceptFriendApplicationRequest{
Meta: httpkit.UserMeta(request, ""),
AccepterUserId: auth.UserIDFromContext(request.Context()),
RequesterUserId: requesterUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{"friend": friendData(resp.GetFriend())})
}
func (h *Handler) deleteFriend(writer http.ResponseWriter, request *http.Request, friendUserID int64) {
if request.Method != http.MethodDelete {
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
resp, err := h.userSocialClient.DeleteFriend(request.Context(), &userv1.DeleteFriendRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
FriendUserId: friendUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{"deleted": resp.GetDeleted()})
}
func socialPage(request *http.Request) (int32, int32, bool) {
page, ok := httpkit.PositiveInt32Query(request, "page", 1)
if !ok {
return 0, 0, false
}
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
if !ok {
return 0, 0, false
}
return page, pageSize, true
}
func profileVisitData(record *userv1.ProfileVisitRecord) profileVisitRecordData {
if record == nil {
return profileVisitRecordData{}
}
data := profileVisitRecordData{
VisitorIdentityHidden: record.GetVisitorIdentityHidden(),
TargetUserID: userIDString(record.GetTargetUserId()),
VisitCount: record.GetVisitCount(),
LastVisitedAtMS: record.GetLastVisitedAtMs(),
}
if record.GetVisitorIdentityHidden() {
// 匿名记录只暴露随机展示 IDvisitor_user_id 使用 omitempty 完全省略,也不存在可继续拉取的 profile。
data.AnonymousVisitID = record.GetAnonymousVisitId()
} else {
data.VisitorUserID = userIDString(record.GetVisitorUserId())
}
return data
}
func followData(record *userv1.FollowRecord) followRecordData {
if record == nil {
return followRecordData{}
}
return followRecordData{
FollowerUserID: userIDString(record.GetFollowerUserId()),
FolloweeUserID: userIDString(record.GetFolloweeUserId()),
FollowedAtMS: record.GetFollowedAtMs(),
}
}
func friendData(record *userv1.FriendRecord) friendRecordData {
if record == nil {
return friendRecordData{}
}
return friendRecordData{
UserID: userIDString(record.GetUserId()),
FriendUserID: userIDString(record.GetFriendUserId()),
FriendedAtMS: record.GetFriendedAtMs(),
}
}
func friendApplicationDataFromProto(application *userv1.FriendApplication) friendApplicationData {
if application == nil {
return friendApplicationData{}
}
return friendApplicationData{
RequesterUserID: userIDString(application.GetRequesterUserId()),
TargetUserID: userIDString(application.GetTargetUserId()),
Status: application.GetStatus(),
CreatedAtMS: application.GetCreatedAtMs(),
UpdatedAtMS: application.GetUpdatedAtMs(),
}
}