fix(user): maintain profile follower counts

This commit is contained in:
zhx 2026-07-21 18:29:25 +08:00
parent 41eaa429af
commit 5d74fb1ff7
15 changed files with 166 additions and 18 deletions

View File

@ -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" +

View File

@ -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

View File

@ -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

View File

@ -33,6 +33,7 @@ Authorization: Bearer <access_token>
"profile_stats": {
"visitors_count": 12,
"following_count": 34,
"followers_count": 78,
"friends_count": 56,
"updated_at_ms": 1777996800000
},
@ -99,7 +100,7 @@ Authorization: Bearer <access_token>
| --- | --- | --- |
| `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 |

View File

@ -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

View File

@ -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));

View File

@ -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)

View File

@ -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) {

View File

@ -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(),
}

View File

@ -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 {

View File

@ -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)

View File

@ -317,6 +317,8 @@ type ProfileStats struct {
VisitorsCount int64
// FollowingCount 是该用户关注的人数计数投影。
FollowingCount int64
// FollowersCount 是关注该用户的人数计数投影;它与 FollowingCount 的方向相反。
FollowersCount int64
// FriendsCount 是该用户好友人数计数投影。
FriendsCount int64
// UpdatedAtMs 是这份计数投影最后更新时间。

View File

@ -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
}

View File

@ -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
}

View File

@ -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,
}