diff --git a/api/proto/user/v1/user.pb.go b/api/proto/user/v1/user.pb.go index 3995966d..7ce4bd73 100644 --- a/api/proto/user/v1/user.pb.go +++ b/api/proto/user/v1/user.pb.go @@ -1977,6 +1977,8 @@ type UserProfileStats struct { FriendsCount int64 `protobuf:"varint,4,opt,name=friends_count,json=friendsCount,proto3" json:"friends_count,omitempty"` UpdatedAtMs int64 `protobuf:"varint,5,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` AppCode string `protobuf:"bytes,6,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` + // followers_count 是关注该用户的人数预聚合;不能在资料页请求中反向 COUNT user_follows。 + FollowersCount int64 `protobuf:"varint,7,opt,name=followers_count,json=followersCount,proto3" json:"followers_count,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2053,6 +2055,13 @@ func (x *UserProfileStats) GetAppCode() string { return "" } +func (x *UserProfileStats) GetFollowersCount() int64 { + if x != nil { + return x.FollowersCount + } + return 0 +} + // GetMyProfileStatsRequest 查询我的页顶部统计计数。 type GetMyProfileStatsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -14081,14 +14090,15 @@ const file_proto_user_v1_user_proto_rawDesc = "" + "\vregion_name\x18\b \x01(\tR\n" + "regionName\"Y\n" + "\x1aBusinessUserLookupResponse\x12;\n" + - "\x05users\x18\x01 \x03(\v2%.hyapp.user.v1.BusinessUserLookupItemR\x05users\"\xdf\x01\n" + + "\x05users\x18\x01 \x03(\v2%.hyapp.user.v1.BusinessUserLookupItemR\x05users\"\x88\x02\n" + "\x10UserProfileStats\x12\x17\n" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12%\n" + "\x0evisitors_count\x18\x02 \x01(\x03R\rvisitorsCount\x12'\n" + "\x0ffollowing_count\x18\x03 \x01(\x03R\x0efollowingCount\x12#\n" + "\rfriends_count\x18\x04 \x01(\x03R\ffriendsCount\x12\"\n" + "\rupdated_at_ms\x18\x05 \x01(\x03R\vupdatedAtMs\x12\x19\n" + - "\bapp_code\x18\x06 \x01(\tR\aappCode\"c\n" + + "\bapp_code\x18\x06 \x01(\tR\aappCode\x12'\n" + + "\x0ffollowers_count\x18\a \x01(\x03R\x0efollowersCount\"c\n" + "\x18GetMyProfileStatsRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x17\n" + "\auser_id\x18\x02 \x01(\x03R\x06userId\"R\n" + diff --git a/api/proto/user/v1/user.proto b/api/proto/user/v1/user.proto index 7dd3f9d3..ae234dee 100644 --- a/api/proto/user/v1/user.proto +++ b/api/proto/user/v1/user.proto @@ -241,6 +241,8 @@ message UserProfileStats { int64 friends_count = 4; int64 updated_at_ms = 5; string app_code = 6; + // followers_count 是关注该用户的人数预聚合;不能在资料页请求中反向 COUNT user_follows。 + int64 followers_count = 7; } // GetMyProfileStatsRequest 查询我的页顶部统计计数。 diff --git a/docs/openapi/gateway.swagger.yaml b/docs/openapi/gateway.swagger.yaml index d46d15be..df39f435 100644 --- a/docs/openapi/gateway.swagger.yaml +++ b/docs/openapi/gateway.swagger.yaml @@ -3862,6 +3862,7 @@ definitions: required: - visitors_count - following_count + - followers_count - friends_count - updated_at_ms properties: @@ -3871,6 +3872,9 @@ definitions: following_count: type: integer format: int64 + followers_count: + type: integer + format: int64 friends_count: type: integer format: int64 diff --git a/docs/我的页概览接口.md b/docs/我的页概览接口.md index a90aa574..d187d176 100644 --- a/docs/我的页概览接口.md +++ b/docs/我的页概览接口.md @@ -33,6 +33,7 @@ Authorization: Bearer "profile_stats": { "visitors_count": 12, "following_count": 34, + "followers_count": 78, "friends_count": 56, "updated_at_ms": 1777996800000 }, @@ -99,7 +100,7 @@ Authorization: Bearer | --- | --- | --- | | `profile` | `user-service.GetUser` | 只取头像、昵称、展示 ID、语言等基础资料 | | `profile.profile_bg_img` | `user-service.GetUser` | 取用户资料里的主页背景图;和 `profile_card` 装扮资源无关,未设置返回空字符串 | -| `profile_stats` | `user-service.GetMyProfileStats` | 读 `user_profile_stats` 计数表,不实时扫访问、关注、好友大表 | +| `profile_stats` | `user-service.GetMyProfileStats` | 读 `user_profile_stats` 计数表,不实时扫访问、关注、粉丝或好友大表 | | `wallet` | `wallet-service.GetWalletValueSummary` | 首屏只返回金币余额,不返回钻石和美元余额 | | `vip` | `wallet-service.GetMyVip` | 只取等级、名称、有效期和 active 状态 | | `roles` | `user-service.GetUserRoleSummary` | 所有身份统一判断,避免 gateway 多 RPC fanout | diff --git a/docs/社交关系接口.md b/docs/社交关系接口.md index 1b605290..f283e8ab 100644 --- a/docs/社交关系接口.md +++ b/docs/社交关系接口.md @@ -60,6 +60,7 @@ sequenceDiagram "target_stats": { "visitors_count": 1, "following_count": 0, + "followers_count": 1, "friends_count": 0, "updated_at_ms": 1777996800000 }, @@ -108,6 +109,7 @@ sequenceDiagram "follower_stats": { "visitors_count": 0, "following_count": 1, + "followers_count": 0, "friends_count": 0, "updated_at_ms": 1777996800000 } @@ -118,13 +120,15 @@ sequenceDiagram | Table | Owner | Purpose | | --- | --- | --- | -| `user_profile_stats` | user-service | 我的页计数 read model:访问人数、关注数、好友数 | +| `user_profile_stats` | user-service | 我的页计数 read model:访问人数、关注数、粉丝数、好友数 | | `user_profile_visits` | user-service | 用户主页访问去重记录,主键 `(app_code,target_user_id,visitor_user_id)` | | `user_follows` | user-service | 单向关注关系,取消关注只改状态 | | `user_friend_applications` | user-service | 好友申请,pending/accepted | | `user_friendships` | user-service | 双向好友边,每个好友关系写两行 | -计数更新和关系边写入必须在同一个 MySQL 事务中完成。`user_profile_stats` 是首屏计数来源,不能在 `/users/me/overview` 里实时扫关系表。 +计数更新和关系边写入必须在同一个 MySQL 事务中完成。关注时同时增加关注者的 `following_count` 和被关注者的 `followers_count`,取消关注时只在 active 状态真实变化时双边递减。`user_profile_stats` 是首屏和资料卡计数来源,不能在 HTTP 查询里实时扫关系表。 + +客态资料卡的 `target_stats` 同时返回 `followers_count`。历史 Fami 客户端曾把该回包的 `friends_count` 渲染为 Followers,因此 gateway 只在 `target_stats` 中保留 `friends_count=followers_count` 兼容别名;`/users/me/overview.profile_stats.friends_count` 始终保持真实好友数。 ## Room Feed Integration diff --git a/scripts/mysql/074_user_profile_followers_count.sql b/scripts/mysql/074_user_profile_followers_count.sql new file mode 100644 index 00000000..53fabd24 --- /dev/null +++ b/scripts/mysql/074_user_profile_followers_count.sql @@ -0,0 +1,46 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_user; + +-- 粉丝数是资料卡高频读模型,新列使用 INSTANT DDL 只修改数据字典, +-- 避免资料卡每次打开都反向 COUNT user_follows。条件 DDL 保持重复执行安全。 +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_profile_stats' + AND COLUMN_NAME = 'followers_count') = 0, + 'ALTER TABLE user_profile_stats + ADD COLUMN followers_count BIGINT NOT NULL DEFAULT 0 COMMENT ''粉丝数量'' AFTER following_count, + ALGORITHM=INSTANT', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 一次按现存 active 关注边回填;分组读取复用 +-- idx_user_follows_followee_status(app_code, followee_user_id, status, updated_at_ms), +-- ON DUPLICATE KEY 同时覆盖已有投影并补齐历史上缺失的被关注者投影行。 +INSERT INTO user_profile_stats ( + app_code, + user_id, + visitors_count, + following_count, + followers_count, + friends_count, + updated_at_ms +) +SELECT + app_code, + followee_user_id, + 0, + 0, + COUNT(*), + 0, + MAX(updated_at_ms) +FROM user_follows FORCE INDEX (idx_user_follows_followee_status) +WHERE status = 'active' +GROUP BY app_code, followee_user_id +ON DUPLICATE KEY UPDATE + followers_count = VALUES(followers_count), + updated_at_ms = GREATEST(user_profile_stats.updated_at_ms, VALUES(updated_at_ms)); diff --git a/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go b/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go index b00ac669..3979b92b 100644 --- a/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go +++ b/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go @@ -32,6 +32,7 @@ func TestGetMyOverviewComposesProfileWalletVIPStatsRolesAndEntries(t *testing.T) UserId: 42, VisitorsCount: 1234, FollowingCount: 2345, + FollowersCount: 3000, FriendsCount: 3456, UpdatedAtMs: 7000, }}, @@ -96,7 +97,7 @@ func TestGetMyOverviewComposesProfileWalletVIPStatsRolesAndEntries(t *testing.T) t.Fatalf("profile data mismatch: %+v", profile) } stats := data["profile_stats"].(map[string]any) - if stats["visitors_count"] != float64(1234) || stats["following_count"] != float64(2345) || stats["friends_count"] != float64(3456) || stats["updated_at_ms"] != float64(7000) { + if stats["visitors_count"] != float64(1234) || stats["following_count"] != float64(2345) || stats["followers_count"] != float64(3000) || stats["friends_count"] != float64(3456) || stats["updated_at_ms"] != float64(7000) { t.Fatalf("profile stats mismatch: %+v", stats) } roles := data["roles"].(map[string]any) @@ -162,7 +163,7 @@ func TestGetMyOverviewProjectsOrdinaryUserEntriesAndZeroWallet(t *testing.T) { t.Fatalf("missing wallet summary should be zero projection: %+v", wallet) } stats := data["profile_stats"].(map[string]any) - if stats["visitors_count"] != float64(0) || stats["following_count"] != float64(0) || stats["friends_count"] != float64(0) { + if stats["visitors_count"] != float64(0) || stats["following_count"] != float64(0) || stats["followers_count"] != float64(0) || stats["friends_count"] != float64(0) { t.Fatalf("missing stats should be zero projection: %+v", stats) } roles := data["roles"].(map[string]any) @@ -219,7 +220,7 @@ func TestGetMyOverviewDegradesOptionalUpstreamFailures(t *testing.T) { t.Fatalf("wallet degradation mismatch: %+v", wallet) } stats := data["profile_stats"].(map[string]any) - if stats["unavailable"] != true || stats["visitors_count"] != float64(0) || stats["following_count"] != float64(0) || stats["friends_count"] != float64(0) { + if stats["unavailable"] != true || stats["visitors_count"] != float64(0) || stats["following_count"] != float64(0) || stats["followers_count"] != float64(0) || stats["friends_count"] != float64(0) { t.Fatalf("profile stats degradation mismatch: %+v", stats) } roles := data["roles"].(map[string]any) diff --git a/services/gateway-service/internal/transport/http/social_handler_test.go b/services/gateway-service/internal/transport/http/social_handler_test.go index 36ae1dd2..0fb91cb8 100644 --- a/services/gateway-service/internal/transport/http/social_handler_test.go +++ b/services/gateway-service/internal/transport/http/social_handler_test.go @@ -29,7 +29,7 @@ type fakeUserSocialClient struct { func (f *fakeUserSocialClient) RecordProfileVisit(_ context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error) { f.lastVisit = req - return &userv1.RecordProfileVisitResponse{Recorded: true, TargetStats: &userv1.UserProfileStats{UserId: req.GetTargetUserId(), VisitorsCount: 1}}, nil + return &userv1.RecordProfileVisitResponse{Recorded: true, TargetStats: &userv1.UserProfileStats{UserId: req.GetTargetUserId(), VisitorsCount: 1, FollowersCount: 2, FriendsCount: 3}}, nil } func (f *fakeUserSocialClient) ListProfileVisitors(_ context.Context, req *userv1.ListProfileVisitorsRequest) (*userv1.ListProfileVisitorsResponse, error) { @@ -165,6 +165,10 @@ func TestProfileVisitReturnsTargetProfileBackground(t *testing.T) { if !ok || profile["user_id"] != "10002" || profile["profile_bg_img"] != "https://cdn.example/target-profile-bg.png" { t.Fatalf("target profile background mismatch: %+v", data["target_profile"]) } + stats, ok := data["target_stats"].(map[string]any) + if !ok || stats["followers_count"] != float64(2) || stats["friends_count"] != float64(2) { + t.Fatalf("target profile followers compatibility mismatch: %+v", data["target_stats"]) + } } func TestSubmitReportRoutePassesAuthenticatedReporterAndSingleTarget(t *testing.T) { diff --git a/services/gateway-service/internal/transport/http/userapi/my_page_overview_handler.go b/services/gateway-service/internal/transport/http/userapi/my_page_overview_handler.go index 741f8f9e..c58f2ed4 100644 --- a/services/gateway-service/internal/transport/http/userapi/my_page_overview_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/my_page_overview_handler.go @@ -51,6 +51,7 @@ type overviewRolesData struct { type overviewProfileStats struct { VisitorsCount int64 `json:"visitors_count"` FollowingCount int64 `json:"following_count"` + FollowersCount int64 `json:"followers_count"` FriendsCount int64 `json:"friends_count"` UpdatedAtMS int64 `json:"updated_at_ms"` Unavailable bool `json:"unavailable,omitempty"` @@ -278,6 +279,7 @@ func overviewStatsFromProto(stats *userv1.UserProfileStats) overviewProfileStats return overviewProfileStats{ VisitorsCount: stats.GetVisitorsCount(), FollowingCount: stats.GetFollowingCount(), + FollowersCount: stats.GetFollowersCount(), FriendsCount: stats.GetFriendsCount(), UpdatedAtMS: stats.GetUpdatedAtMs(), } diff --git a/services/gateway-service/internal/transport/http/userapi/social_handler.go b/services/gateway-service/internal/transport/http/userapi/social_handler.go index 144e4307..b2c19797 100644 --- a/services/gateway-service/internal/transport/http/userapi/social_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/social_handler.go @@ -217,7 +217,7 @@ func (h *Handler) recordProfileVisit(writer http.ResponseWriter, request *http.R } if !resp.GetTargetStatsHidden() { // 隐藏标志为 true 时不写 target_stats key;不能把 nil 转成零值 DTO,否则客户端会把伪造的 0 当成真实统计。 - data["target_stats"] = overviewStatsFromProto(resp.GetTargetStats()) + data["target_stats"] = targetProfileStatsFromProto(resp.GetTargetStats()) } if h.userProfileClient != nil { profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ @@ -232,6 +232,15 @@ func (h *Handler) recordProfileVisit(writer http.ResponseWriter, request *http.R httpkit.WriteOK(writer, request, data) } +// targetProfileStatsFromProto 为客态资料卡返回粉丝计数。已发布的 Fami 客户端曾把 +// target_stats.friends_count 当作 Followers 读取,因此在新 followers_count 合同外保留该客态回包别名; +// /users/me/overview 仍返回真实好友数,不会污染好友列表语义。 +func targetProfileStatsFromProto(stats *userv1.UserProfileStats) overviewProfileStats { + result := overviewStatsFromProto(stats) + result.FriendsCount = result.FollowersCount + return result +} + func (h *Handler) handleFollowAction(writer http.ResponseWriter, request *http.Request, followeeUserID int64) { userID := auth.UserIDFromContext(request.Context()) switch request.Method { diff --git a/services/user-service/deploy/mysql/initdb/001_user_service.sql b/services/user-service/deploy/mysql/initdb/001_user_service.sql index 078853b7..97bc85a1 100644 --- a/services/user-service/deploy/mysql/initdb/001_user_service.sql +++ b/services/user-service/deploy/mysql/initdb/001_user_service.sql @@ -125,6 +125,7 @@ CREATE TABLE IF NOT EXISTS user_profile_stats ( user_id BIGINT NOT NULL COMMENT '用户 ID', visitors_count BIGINT NOT NULL DEFAULT 0 COMMENT '访客数量', following_count BIGINT NOT NULL DEFAULT 0 COMMENT '关注中数量', + followers_count BIGINT NOT NULL DEFAULT 0 COMMENT '粉丝数量', friends_count BIGINT NOT NULL DEFAULT 0 COMMENT '好友数量', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, user_id) diff --git a/services/user-service/internal/domain/user/user.go b/services/user-service/internal/domain/user/user.go index 740ad21c..8d6c560d 100644 --- a/services/user-service/internal/domain/user/user.go +++ b/services/user-service/internal/domain/user/user.go @@ -317,6 +317,8 @@ type ProfileStats struct { VisitorsCount int64 // FollowingCount 是该用户关注的人数计数投影。 FollowingCount int64 + // FollowersCount 是关注该用户的人数计数投影;它与 FollowingCount 的方向相反。 + FollowersCount int64 // FriendsCount 是该用户好友人数计数投影。 FriendsCount int64 // UpdatedAtMs 是这份计数投影最后更新时间。 diff --git a/services/user-service/internal/storage/mysql/user/repository.go b/services/user-service/internal/storage/mysql/user/repository.go index fcedc7e5..1f2009cb 100644 --- a/services/user-service/internal/storage/mysql/user/repository.go +++ b/services/user-service/internal/storage/mysql/user/repository.go @@ -43,11 +43,11 @@ func (r *Repository) GetMyProfileStats(ctx context.Context, userID int64) (userd appCode := appcode.FromContext(ctx) stats := userdomain.ProfileStats{AppCode: appCode, UserID: userID} err := r.db.QueryRowContext(ctx, ` - SELECT visitors_count, following_count, friends_count, updated_at_ms + SELECT visitors_count, following_count, followers_count, friends_count, updated_at_ms FROM user_profile_stats WHERE app_code = ? AND user_id = ?`, appCode, userID, - ).Scan(&stats.VisitorsCount, &stats.FollowingCount, &stats.FriendsCount, &stats.UpdatedAtMs) + ).Scan(&stats.VisitorsCount, &stats.FollowingCount, &stats.FollowersCount, &stats.FriendsCount, &stats.UpdatedAtMs) if err == sql.ErrNoRows { return stats, nil } diff --git a/services/user-service/internal/storage/mysql/user/social_repository.go b/services/user-service/internal/storage/mysql/user/social_repository.go index 38e186c9..09ca11ce 100644 --- a/services/user-service/internal/storage/mysql/user/social_repository.go +++ b/services/user-service/internal/storage/mysql/user/social_repository.go @@ -301,7 +301,7 @@ func (r *Repository) FollowUser(ctx context.Context, followerUserID int64, follo if err := ensureSocialUser(ctx, tx, appCode, followeeUserID); err != nil { return userdomain.ProfileStats{}, err } - if err := ensureProfileStatsRow(ctx, tx, appCode, followerUserID, nowMs); err != nil { + if err := ensureProfileStatsRows(ctx, tx, appCode, followerUserID, followeeUserID, nowMs); err != nil { return userdomain.ProfileStats{}, err } status, exists, err := lockFollowStatus(ctx, tx, appCode, followerUserID, followeeUserID) @@ -346,6 +346,16 @@ func (r *Repository) FollowUser(ctx context.Context, followerUserID int64, follo ); err != nil { return userdomain.ProfileStats{}, err } + // 关注关系和双边计数必须在同一事务提交;资料页只读 followee 的预聚合值, + // 不在每次打开资料卡时反向扫描 user_follows。 + if _, err := tx.ExecContext(ctx, ` + UPDATE user_profile_stats + SET followers_count = followers_count + 1, updated_at_ms = ? + WHERE app_code = ? AND user_id = ?`, + nowMs, appCode, followeeUserID, + ); err != nil { + return userdomain.ProfileStats{}, err + } } stats, err := queryProfileStatsTx(ctx, tx, appCode, followerUserID) if err != nil { @@ -362,7 +372,7 @@ func (r *Repository) UnfollowUser(ctx context.Context, followerUserID int64, fol defer tx.Rollback() appCode := appcode.FromContext(ctx) - if err := ensureProfileStatsRow(ctx, tx, appCode, followerUserID, nowMs); err != nil { + if err := ensureProfileStatsRows(ctx, tx, appCode, followerUserID, followeeUserID, nowMs); err != nil { return userdomain.ProfileStats{}, err } status, exists, err := lockFollowStatus(ctx, tx, appCode, followerUserID, followeeUserID) @@ -395,6 +405,15 @@ func (r *Repository) UnfollowUser(ctx context.Context, followerUserID int64, fol ); err != nil { return userdomain.ProfileStats{}, err } + // 幂等取消只在 active -> cancelled 时递减;GREATEST 防止历史脏计数被扣成负数。 + if _, err := tx.ExecContext(ctx, ` + UPDATE user_profile_stats + SET followers_count = GREATEST(followers_count - 1, 0), updated_at_ms = ? + WHERE app_code = ? AND user_id = ?`, + nowMs, appCode, followeeUserID, + ); err != nil { + return userdomain.ProfileStats{}, err + } } stats, err := queryProfileStatsTx(ctx, tx, appCode, followerUserID) if err != nil { @@ -694,23 +713,65 @@ func ensureSocialUser(ctx context.Context, tx *sql.Tx, appCode string, userID in func ensureProfileStatsRow(ctx context.Context, tx *sql.Tx, appCode string, userID int64, nowMs int64) error { _, err := tx.ExecContext(ctx, ` INSERT IGNORE INTO user_profile_stats ( - app_code, user_id, visitors_count, following_count, friends_count, updated_at_ms - ) VALUES (?, ?, 0, 0, 0, ?)`, + app_code, user_id, visitors_count, following_count, followers_count, friends_count, updated_at_ms + ) VALUES (?, ?, 0, 0, 0, 0, ?)`, appCode, userID, nowMs, ) return err } +func ensureProfileStatsRows(ctx context.Context, tx *sql.Tx, appCode string, leftUserID int64, rightUserID int64, nowMs int64) error { + // 双边关注计数总是按 user_id 升序触碰投影行,避免 A->B 与 B->A 并发关注时 + // 以相反顺序获取 INSERT/UPDATE 行锁形成可避免的死锁。INSERT IGNORE 只保证行存在, + // 后续 FOR UPDATE 才把两行以固定顺序持有到事务结束。 + if leftUserID > rightUserID { + leftUserID, rightUserID = rightUserID, leftUserID + } + if err := ensureProfileStatsRow(ctx, tx, appCode, leftUserID, nowMs); err != nil { + return err + } + if leftUserID == rightUserID { + var lockedUserID int64 + return tx.QueryRowContext(ctx, ` + SELECT user_id FROM user_profile_stats + WHERE app_code = ? AND user_id = ? + FOR UPDATE`, + appCode, leftUserID, + ).Scan(&lockedUserID) + } + if err := ensureProfileStatsRow(ctx, tx, appCode, rightUserID, nowMs); err != nil { + return err + } + rows, err := tx.QueryContext(ctx, ` + SELECT user_id FROM user_profile_stats + WHERE app_code = ? AND user_id IN (?, ?) + ORDER BY user_id + FOR UPDATE`, + appCode, leftUserID, rightUserID, + ) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var lockedUserID int64 + if err := rows.Scan(&lockedUserID); err != nil { + return err + } + } + return rows.Err() +} + func queryProfileStatsTx(ctx context.Context, tx *sql.Tx, appCode string, userID int64) (userdomain.ProfileStats, error) { var stats userdomain.ProfileStats stats.AppCode = appCode stats.UserID = userID err := tx.QueryRowContext(ctx, ` - SELECT visitors_count, following_count, friends_count, updated_at_ms + SELECT visitors_count, following_count, followers_count, friends_count, updated_at_ms FROM user_profile_stats WHERE app_code = ? AND user_id = ?`, appCode, userID, - ).Scan(&stats.VisitorsCount, &stats.FollowingCount, &stats.FriendsCount, &stats.UpdatedAtMs) + ).Scan(&stats.VisitorsCount, &stats.FollowingCount, &stats.FollowersCount, &stats.FriendsCount, &stats.UpdatedAtMs) return stats, err } diff --git a/services/user-service/internal/transport/grpc/convert.go b/services/user-service/internal/transport/grpc/convert.go index ec5b1a67..bc8bf8dd 100644 --- a/services/user-service/internal/transport/grpc/convert.go +++ b/services/user-service/internal/transport/grpc/convert.go @@ -239,6 +239,7 @@ func toProtoProfileStats(stats userdomain.ProfileStats) *userv1.UserProfileStats UserId: stats.UserID, VisitorsCount: stats.VisitorsCount, FollowingCount: stats.FollowingCount, + FollowersCount: stats.FollowersCount, FriendsCount: stats.FriendsCount, UpdatedAtMs: stats.UpdatedAtMs, }