fix(user): maintain profile follower counts
This commit is contained in:
parent
41eaa429af
commit
5d74fb1ff7
@ -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" +
|
||||
|
||||
@ -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 查询我的页顶部统计计数。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
46
scripts/mysql/074_user_profile_followers_count.sql
Normal file
46
scripts/mysql/074_user_profile_followers_count.sql
Normal 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));
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -317,6 +317,8 @@ type ProfileStats struct {
|
||||
VisitorsCount int64
|
||||
// FollowingCount 是该用户关注的人数计数投影。
|
||||
FollowingCount int64
|
||||
// FollowersCount 是关注该用户的人数计数投影;它与 FollowingCount 的方向相反。
|
||||
FollowersCount int64
|
||||
// FriendsCount 是该用户好友人数计数投影。
|
||||
FriendsCount int64
|
||||
// UpdatedAtMs 是这份计数投影最后更新时间。
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user