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"`
|
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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -2053,6 +2055,13 @@ func (x *UserProfileStats) GetAppCode() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *UserProfileStats) GetFollowersCount() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.FollowersCount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// GetMyProfileStatsRequest 查询我的页顶部统计计数。
|
// GetMyProfileStatsRequest 查询我的页顶部统计计数。
|
||||||
type GetMyProfileStatsRequest struct {
|
type GetMyProfileStatsRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
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" +
|
"\vregion_name\x18\b \x01(\tR\n" +
|
||||||
"regionName\"Y\n" +
|
"regionName\"Y\n" +
|
||||||
"\x1aBusinessUserLookupResponse\x12;\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" +
|
"\x10UserProfileStats\x12\x17\n" +
|
||||||
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12%\n" +
|
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12%\n" +
|
||||||
"\x0evisitors_count\x18\x02 \x01(\x03R\rvisitorsCount\x12'\n" +
|
"\x0evisitors_count\x18\x02 \x01(\x03R\rvisitorsCount\x12'\n" +
|
||||||
"\x0ffollowing_count\x18\x03 \x01(\x03R\x0efollowingCount\x12#\n" +
|
"\x0ffollowing_count\x18\x03 \x01(\x03R\x0efollowingCount\x12#\n" +
|
||||||
"\rfriends_count\x18\x04 \x01(\x03R\ffriendsCount\x12\"\n" +
|
"\rfriends_count\x18\x04 \x01(\x03R\ffriendsCount\x12\"\n" +
|
||||||
"\rupdated_at_ms\x18\x05 \x01(\x03R\vupdatedAtMs\x12\x19\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" +
|
"\x18GetMyProfileStatsRequest\x12.\n" +
|
||||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x17\n" +
|
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x17\n" +
|
||||||
"\auser_id\x18\x02 \x01(\x03R\x06userId\"R\n" +
|
"\auser_id\x18\x02 \x01(\x03R\x06userId\"R\n" +
|
||||||
|
|||||||
@ -241,6 +241,8 @@ message UserProfileStats {
|
|||||||
int64 friends_count = 4;
|
int64 friends_count = 4;
|
||||||
int64 updated_at_ms = 5;
|
int64 updated_at_ms = 5;
|
||||||
string app_code = 6;
|
string app_code = 6;
|
||||||
|
// followers_count 是关注该用户的人数预聚合;不能在资料页请求中反向 COUNT user_follows。
|
||||||
|
int64 followers_count = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMyProfileStatsRequest 查询我的页顶部统计计数。
|
// GetMyProfileStatsRequest 查询我的页顶部统计计数。
|
||||||
|
|||||||
@ -3862,6 +3862,7 @@ definitions:
|
|||||||
required:
|
required:
|
||||||
- visitors_count
|
- visitors_count
|
||||||
- following_count
|
- following_count
|
||||||
|
- followers_count
|
||||||
- friends_count
|
- friends_count
|
||||||
- updated_at_ms
|
- updated_at_ms
|
||||||
properties:
|
properties:
|
||||||
@ -3871,6 +3872,9 @@ definitions:
|
|||||||
following_count:
|
following_count:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
|
followers_count:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
friends_count:
|
friends_count:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
|
|||||||
@ -33,6 +33,7 @@ Authorization: Bearer <access_token>
|
|||||||
"profile_stats": {
|
"profile_stats": {
|
||||||
"visitors_count": 12,
|
"visitors_count": 12,
|
||||||
"following_count": 34,
|
"following_count": 34,
|
||||||
|
"followers_count": 78,
|
||||||
"friends_count": 56,
|
"friends_count": 56,
|
||||||
"updated_at_ms": 1777996800000
|
"updated_at_ms": 1777996800000
|
||||||
},
|
},
|
||||||
@ -99,7 +100,7 @@ Authorization: Bearer <access_token>
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `profile` | `user-service.GetUser` | 只取头像、昵称、展示 ID、语言等基础资料 |
|
| `profile` | `user-service.GetUser` | 只取头像、昵称、展示 ID、语言等基础资料 |
|
||||||
| `profile.profile_bg_img` | `user-service.GetUser` | 取用户资料里的主页背景图;和 `profile_card` 装扮资源无关,未设置返回空字符串 |
|
| `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` | 首屏只返回金币余额,不返回钻石和美元余额 |
|
| `wallet` | `wallet-service.GetWalletValueSummary` | 首屏只返回金币余额,不返回钻石和美元余额 |
|
||||||
| `vip` | `wallet-service.GetMyVip` | 只取等级、名称、有效期和 active 状态 |
|
| `vip` | `wallet-service.GetMyVip` | 只取等级、名称、有效期和 active 状态 |
|
||||||
| `roles` | `user-service.GetUserRoleSummary` | 所有身份统一判断,避免 gateway 多 RPC fanout |
|
| `roles` | `user-service.GetUserRoleSummary` | 所有身份统一判断,避免 gateway 多 RPC fanout |
|
||||||
|
|||||||
@ -60,6 +60,7 @@ sequenceDiagram
|
|||||||
"target_stats": {
|
"target_stats": {
|
||||||
"visitors_count": 1,
|
"visitors_count": 1,
|
||||||
"following_count": 0,
|
"following_count": 0,
|
||||||
|
"followers_count": 1,
|
||||||
"friends_count": 0,
|
"friends_count": 0,
|
||||||
"updated_at_ms": 1777996800000
|
"updated_at_ms": 1777996800000
|
||||||
},
|
},
|
||||||
@ -108,6 +109,7 @@ sequenceDiagram
|
|||||||
"follower_stats": {
|
"follower_stats": {
|
||||||
"visitors_count": 0,
|
"visitors_count": 0,
|
||||||
"following_count": 1,
|
"following_count": 1,
|
||||||
|
"followers_count": 0,
|
||||||
"friends_count": 0,
|
"friends_count": 0,
|
||||||
"updated_at_ms": 1777996800000
|
"updated_at_ms": 1777996800000
|
||||||
}
|
}
|
||||||
@ -118,13 +120,15 @@ sequenceDiagram
|
|||||||
|
|
||||||
| Table | Owner | Purpose |
|
| 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_profile_visits` | user-service | 用户主页访问去重记录,主键 `(app_code,target_user_id,visitor_user_id)` |
|
||||||
| `user_follows` | user-service | 单向关注关系,取消关注只改状态 |
|
| `user_follows` | user-service | 单向关注关系,取消关注只改状态 |
|
||||||
| `user_friend_applications` | user-service | 好友申请,pending/accepted |
|
| `user_friend_applications` | user-service | 好友申请,pending/accepted |
|
||||||
| `user_friendships` | user-service | 双向好友边,每个好友关系写两行 |
|
| `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
|
## 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,
|
UserId: 42,
|
||||||
VisitorsCount: 1234,
|
VisitorsCount: 1234,
|
||||||
FollowingCount: 2345,
|
FollowingCount: 2345,
|
||||||
|
FollowersCount: 3000,
|
||||||
FriendsCount: 3456,
|
FriendsCount: 3456,
|
||||||
UpdatedAtMs: 7000,
|
UpdatedAtMs: 7000,
|
||||||
}},
|
}},
|
||||||
@ -96,7 +97,7 @@ func TestGetMyOverviewComposesProfileWalletVIPStatsRolesAndEntries(t *testing.T)
|
|||||||
t.Fatalf("profile data mismatch: %+v", profile)
|
t.Fatalf("profile data mismatch: %+v", profile)
|
||||||
}
|
}
|
||||||
stats := data["profile_stats"].(map[string]any)
|
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)
|
t.Fatalf("profile stats mismatch: %+v", stats)
|
||||||
}
|
}
|
||||||
roles := data["roles"].(map[string]any)
|
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)
|
t.Fatalf("missing wallet summary should be zero projection: %+v", wallet)
|
||||||
}
|
}
|
||||||
stats := data["profile_stats"].(map[string]any)
|
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)
|
t.Fatalf("missing stats should be zero projection: %+v", stats)
|
||||||
}
|
}
|
||||||
roles := data["roles"].(map[string]any)
|
roles := data["roles"].(map[string]any)
|
||||||
@ -219,7 +220,7 @@ func TestGetMyOverviewDegradesOptionalUpstreamFailures(t *testing.T) {
|
|||||||
t.Fatalf("wallet degradation mismatch: %+v", wallet)
|
t.Fatalf("wallet degradation mismatch: %+v", wallet)
|
||||||
}
|
}
|
||||||
stats := data["profile_stats"].(map[string]any)
|
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)
|
t.Fatalf("profile stats degradation mismatch: %+v", stats)
|
||||||
}
|
}
|
||||||
roles := data["roles"].(map[string]any)
|
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) {
|
func (f *fakeUserSocialClient) RecordProfileVisit(_ context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error) {
|
||||||
f.lastVisit = req
|
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) {
|
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" {
|
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"])
|
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) {
|
func TestSubmitReportRoutePassesAuthenticatedReporterAndSingleTarget(t *testing.T) {
|
||||||
|
|||||||
@ -51,6 +51,7 @@ type overviewRolesData struct {
|
|||||||
type overviewProfileStats struct {
|
type overviewProfileStats struct {
|
||||||
VisitorsCount int64 `json:"visitors_count"`
|
VisitorsCount int64 `json:"visitors_count"`
|
||||||
FollowingCount int64 `json:"following_count"`
|
FollowingCount int64 `json:"following_count"`
|
||||||
|
FollowersCount int64 `json:"followers_count"`
|
||||||
FriendsCount int64 `json:"friends_count"`
|
FriendsCount int64 `json:"friends_count"`
|
||||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
Unavailable bool `json:"unavailable,omitempty"`
|
Unavailable bool `json:"unavailable,omitempty"`
|
||||||
@ -278,6 +279,7 @@ func overviewStatsFromProto(stats *userv1.UserProfileStats) overviewProfileStats
|
|||||||
return overviewProfileStats{
|
return overviewProfileStats{
|
||||||
VisitorsCount: stats.GetVisitorsCount(),
|
VisitorsCount: stats.GetVisitorsCount(),
|
||||||
FollowingCount: stats.GetFollowingCount(),
|
FollowingCount: stats.GetFollowingCount(),
|
||||||
|
FollowersCount: stats.GetFollowersCount(),
|
||||||
FriendsCount: stats.GetFriendsCount(),
|
FriendsCount: stats.GetFriendsCount(),
|
||||||
UpdatedAtMS: stats.GetUpdatedAtMs(),
|
UpdatedAtMS: stats.GetUpdatedAtMs(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -217,7 +217,7 @@ func (h *Handler) recordProfileVisit(writer http.ResponseWriter, request *http.R
|
|||||||
}
|
}
|
||||||
if !resp.GetTargetStatsHidden() {
|
if !resp.GetTargetStatsHidden() {
|
||||||
// 隐藏标志为 true 时不写 target_stats key;不能把 nil 转成零值 DTO,否则客户端会把伪造的 0 当成真实统计。
|
// 隐藏标志为 true 时不写 target_stats key;不能把 nil 转成零值 DTO,否则客户端会把伪造的 0 当成真实统计。
|
||||||
data["target_stats"] = overviewStatsFromProto(resp.GetTargetStats())
|
data["target_stats"] = targetProfileStatsFromProto(resp.GetTargetStats())
|
||||||
}
|
}
|
||||||
if h.userProfileClient != nil {
|
if h.userProfileClient != nil {
|
||||||
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
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)
|
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) {
|
func (h *Handler) handleFollowAction(writer http.ResponseWriter, request *http.Request, followeeUserID int64) {
|
||||||
userID := auth.UserIDFromContext(request.Context())
|
userID := auth.UserIDFromContext(request.Context())
|
||||||
switch request.Method {
|
switch request.Method {
|
||||||
|
|||||||
@ -125,6 +125,7 @@ CREATE TABLE IF NOT EXISTS user_profile_stats (
|
|||||||
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
visitors_count BIGINT NOT NULL DEFAULT 0 COMMENT '访客数量',
|
visitors_count BIGINT NOT NULL DEFAULT 0 COMMENT '访客数量',
|
||||||
following_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 '好友数量',
|
friends_count BIGINT NOT NULL DEFAULT 0 COMMENT '好友数量',
|
||||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
PRIMARY KEY (app_code, user_id)
|
PRIMARY KEY (app_code, user_id)
|
||||||
|
|||||||
@ -317,6 +317,8 @@ type ProfileStats struct {
|
|||||||
VisitorsCount int64
|
VisitorsCount int64
|
||||||
// FollowingCount 是该用户关注的人数计数投影。
|
// FollowingCount 是该用户关注的人数计数投影。
|
||||||
FollowingCount int64
|
FollowingCount int64
|
||||||
|
// FollowersCount 是关注该用户的人数计数投影;它与 FollowingCount 的方向相反。
|
||||||
|
FollowersCount int64
|
||||||
// FriendsCount 是该用户好友人数计数投影。
|
// FriendsCount 是该用户好友人数计数投影。
|
||||||
FriendsCount int64
|
FriendsCount int64
|
||||||
// UpdatedAtMs 是这份计数投影最后更新时间。
|
// UpdatedAtMs 是这份计数投影最后更新时间。
|
||||||
|
|||||||
@ -43,11 +43,11 @@ func (r *Repository) GetMyProfileStats(ctx context.Context, userID int64) (userd
|
|||||||
appCode := appcode.FromContext(ctx)
|
appCode := appcode.FromContext(ctx)
|
||||||
stats := userdomain.ProfileStats{AppCode: appCode, UserID: userID}
|
stats := userdomain.ProfileStats{AppCode: appCode, UserID: userID}
|
||||||
err := r.db.QueryRowContext(ctx, `
|
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
|
FROM user_profile_stats
|
||||||
WHERE app_code = ? AND user_id = ?`,
|
WHERE app_code = ? AND user_id = ?`,
|
||||||
appCode, userID,
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return stats, nil
|
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 {
|
if err := ensureSocialUser(ctx, tx, appCode, followeeUserID); err != nil {
|
||||||
return userdomain.ProfileStats{}, err
|
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
|
return userdomain.ProfileStats{}, err
|
||||||
}
|
}
|
||||||
status, exists, err := lockFollowStatus(ctx, tx, appCode, followerUserID, followeeUserID)
|
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 {
|
); err != nil {
|
||||||
return userdomain.ProfileStats{}, err
|
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)
|
stats, err := queryProfileStatsTx(ctx, tx, appCode, followerUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -362,7 +372,7 @@ func (r *Repository) UnfollowUser(ctx context.Context, followerUserID int64, fol
|
|||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
appCode := appcode.FromContext(ctx)
|
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
|
return userdomain.ProfileStats{}, err
|
||||||
}
|
}
|
||||||
status, exists, err := lockFollowStatus(ctx, tx, appCode, followerUserID, followeeUserID)
|
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 {
|
); err != nil {
|
||||||
return userdomain.ProfileStats{}, err
|
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)
|
stats, err := queryProfileStatsTx(ctx, tx, appCode, followerUserID)
|
||||||
if err != nil {
|
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 {
|
func ensureProfileStatsRow(ctx context.Context, tx *sql.Tx, appCode string, userID int64, nowMs int64) error {
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
INSERT IGNORE INTO user_profile_stats (
|
INSERT IGNORE INTO user_profile_stats (
|
||||||
app_code, user_id, visitors_count, following_count, friends_count, updated_at_ms
|
app_code, user_id, visitors_count, following_count, followers_count, friends_count, updated_at_ms
|
||||||
) VALUES (?, ?, 0, 0, 0, ?)`,
|
) VALUES (?, ?, 0, 0, 0, 0, ?)`,
|
||||||
appCode, userID, nowMs,
|
appCode, userID, nowMs,
|
||||||
)
|
)
|
||||||
return err
|
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) {
|
func queryProfileStatsTx(ctx context.Context, tx *sql.Tx, appCode string, userID int64) (userdomain.ProfileStats, error) {
|
||||||
var stats userdomain.ProfileStats
|
var stats userdomain.ProfileStats
|
||||||
stats.AppCode = appCode
|
stats.AppCode = appCode
|
||||||
stats.UserID = userID
|
stats.UserID = userID
|
||||||
err := tx.QueryRowContext(ctx, `
|
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
|
FROM user_profile_stats
|
||||||
WHERE app_code = ? AND user_id = ?`,
|
WHERE app_code = ? AND user_id = ?`,
|
||||||
appCode, userID,
|
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
|
return stats, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -239,6 +239,7 @@ func toProtoProfileStats(stats userdomain.ProfileStats) *userv1.UserProfileStats
|
|||||||
UserId: stats.UserID,
|
UserId: stats.UserID,
|
||||||
VisitorsCount: stats.VisitorsCount,
|
VisitorsCount: stats.VisitorsCount,
|
||||||
FollowingCount: stats.FollowingCount,
|
FollowingCount: stats.FollowingCount,
|
||||||
|
FollowersCount: stats.FollowersCount,
|
||||||
FriendsCount: stats.FriendsCount,
|
FriendsCount: stats.FriendsCount,
|
||||||
UpdatedAtMs: stats.UpdatedAtMs,
|
UpdatedAtMs: stats.UpdatedAtMs,
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user