feat: sync expose gramsrv account rating

Follow up PR #22 by projecting its composite rating through userFull while keeping profile reads cache-backed and strictly read-only.

Source-Commit: 00eea44c

Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
This commit is contained in:
iamxvbaba 2026-07-28 02:09:29 +08:00
parent fff8de783a
commit 74c9249091
9 changed files with 362 additions and 31 deletions

View file

@ -0,0 +1,272 @@
package rpc
import (
"context"
"errors"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type fakeAccountRatingProjection struct {
byUser map[int64]domain.AccountRating
err error
ratingCalls int
ensureCalls int
}
func (f *fakeAccountRatingProjection) Rating(_ context.Context, userID int64) (domain.AccountRating, error) {
f.ratingCalls++
if f.err != nil {
return domain.AccountRating{}, f.err
}
rating, ok := f.byUser[userID]
if !ok {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
return rating, nil
}
// EnsureRating deliberately exists on the fake even though it is not part of
// AccountRatingService. The assertion below pins that profile reads stay
// read-only if a future implementation happens to expose a materializer.
func (f *fakeAccountRatingProjection) EnsureRating(_ context.Context, userID int64) (domain.AccountRating, error) {
f.ensureCalls++
return f.byUser[userID], nil
}
var _ AccountRatingService = (*fakeAccountRatingProjection)(nil)
func newAccountRatingProjectionFixture(t *testing.T, ratings AccountRatingService) (*Router, domain.User, domain.User) {
t.Helper()
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{
AccessHash: 11,
Phone: "15550004001",
FirstName: "Owner",
})
if err != nil {
t.Fatalf("create owner: %v", err)
}
other, err := userStore.Create(ctx, domain.User{
AccessHash: 22,
Phone: "15550004002",
FirstName: "Other",
})
if err != nil {
t.Fatalf("create other: %v", err)
}
router := New(Config{}, Deps{
Users: appusers.NewService(userStore),
AccountRatings: ratings,
}, zaptest.NewLogger(t), clock.System)
return router, owner, other
}
func TestUserFullProjectsCompositeRatingReadOnlyAndCachesIt(t *testing.T) {
pendingDate := time.Unix(1800000000, 0).UTC()
ratings := &fakeAccountRatingProjection{byUser: make(map[int64]domain.AccountRating)}
router, owner, _ := newAccountRatingProjectionFixture(t, ratings)
ratings.byUser[owner.ID] = domain.AccountRating{
UserID: owner.ID,
Level: 3,
Stars: 1200,
CurrentLevelStars: domain.AccountRatingLevelThreshold(3),
NextLevelStars: domain.AccountRatingLevelThreshold(4),
HasNextLevel: true,
PendingStars: 500,
PendingDate: pendingDate,
}
ctx := WithUserID(context.Background(), owner.ID)
full, err := router.onUsersGetFullUser(ctx, &tg.InputUserSelf{})
if err != nil {
t.Fatalf("get self full user: %v", err)
}
rating, ok := full.FullUser.GetStarsRating()
if !ok || rating.Level != 3 || rating.Stars != 1200 ||
rating.CurrentLevelStars != domain.AccountRatingLevelThreshold(3) {
t.Fatalf("self rating = %+v (present=%v)", rating, ok)
}
if next, ok := rating.GetNextLevelStars(); !ok || next != domain.AccountRatingLevelThreshold(4) {
t.Fatalf("self next_level_stars = %d (present=%v)", next, ok)
}
pending, ok := full.FullUser.GetStarsMyPendingRating()
if !ok || pending.Stars != 1700 {
t.Fatalf("self pending rating = %+v (present=%v)", pending, ok)
}
if date, ok := full.FullUser.GetStarsMyPendingRatingDate(); !ok || date != int(pendingDate.Unix()) {
t.Fatalf("self pending date = %d (present=%v)", date, ok)
}
if ratings.ratingCalls != 1 || ratings.ensureCalls != 0 {
t.Fatalf("rating calls=%d ensure calls=%d, want 1/0", ratings.ratingCalls, ratings.ensureCalls)
}
// The second response is served from the existing UserFull projection cache:
// the rating read cannot become a per-request database query.
if _, err := router.onUsersGetFullUser(ctx, &tg.InputUserSelf{}); err != nil {
t.Fatalf("get cached self full user: %v", err)
}
if ratings.ratingCalls != 1 || ratings.ensureCalls != 0 {
t.Fatalf("cached rating calls=%d ensure calls=%d, want 1/0", ratings.ratingCalls, ratings.ensureCalls)
}
}
func TestUserFullRatingPendingIsSelfOnly(t *testing.T) {
pendingDate := time.Unix(1800000000, 0).UTC()
ratings := &fakeAccountRatingProjection{byUser: make(map[int64]domain.AccountRating)}
router, owner, other := newAccountRatingProjectionFixture(t, ratings)
ratings.byUser[other.ID] = domain.AccountRating{
UserID: other.ID,
Level: 1,
Stars: 150,
CurrentLevelStars: domain.AccountRatingLevelThreshold(1),
NextLevelStars: domain.AccountRatingLevelThreshold(2),
HasNextLevel: true,
PendingStars: 500,
PendingDate: pendingDate,
}
full, err := router.onUsersGetFullUser(
WithUserID(context.Background(), owner.ID),
&tg.InputUser{UserID: other.ID, AccessHash: other.AccessHash},
)
if err != nil {
t.Fatalf("get other full user: %v", err)
}
if rating, ok := full.FullUser.GetStarsRating(); !ok || rating.Level != 1 || rating.Stars != 150 {
t.Fatalf("other rating = %+v (present=%v)", rating, ok)
}
if _, ok := full.FullUser.GetStarsMyPendingRating(); ok {
t.Fatal("other pending rating is visible")
}
if _, ok := full.FullUser.GetStarsMyPendingRatingDate(); ok {
t.Fatal("other pending rating date is visible")
}
}
func TestUserFullRatingDegradesWithoutStoredProjection(t *testing.T) {
tests := []struct {
name string
ratings AccountRatingService
}{
{name: "service absent"},
{name: "row missing", ratings: &fakeAccountRatingProjection{byUser: map[int64]domain.AccountRating{}}},
{name: "read failure", ratings: &fakeAccountRatingProjection{
byUser: map[int64]domain.AccountRating{},
err: errors.New("rating unavailable"),
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router, owner, _ := newAccountRatingProjectionFixture(t, tt.ratings)
full, err := router.onUsersGetFullUser(
WithUserID(context.Background(), owner.ID),
&tg.InputUserSelf{},
)
if err != nil {
t.Fatalf("get full user: %v", err)
}
if _, ok := full.FullUser.GetStarsRating(); ok {
t.Fatal("rating set without a stored projection")
}
if _, ok := full.FullUser.GetStarsMyPendingRating(); ok {
t.Fatal("pending rating set without a stored projection")
}
})
}
}
func TestUserFullRatingOmitsBotsAndTopLevelThreshold(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
viewer, err := userStore.Create(ctx, domain.User{
AccessHash: 31,
Phone: "15550004101",
FirstName: "Viewer",
})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
bot, err := userStore.Create(ctx, domain.User{
AccessHash: 32,
Phone: "15550004102",
FirstName: "Helper",
Bot: true,
})
if err != nil {
t.Fatalf("create bot: %v", err)
}
ratings := &fakeAccountRatingProjection{byUser: map[int64]domain.AccountRating{
viewer.ID: {
UserID: viewer.ID,
Level: domain.MaxAccountRatingLevel,
Stars: domain.AccountRatingLevelThreshold(domain.MaxAccountRatingLevel),
CurrentLevelStars: domain.AccountRatingLevelThreshold(domain.MaxAccountRatingLevel),
},
bot.ID: {
UserID: bot.ID,
Level: 4,
Stars: 2000,
},
domain.OfficialSystemUserID: {
UserID: domain.OfficialSystemUserID,
Level: 5,
Stars: 3000,
},
}}
router := New(Config{}, Deps{
Users: appusers.NewService(userStore),
AccountRatings: ratings,
}, zaptest.NewLogger(t), clock.System)
viewerCtx := WithUserID(ctx, viewer.ID)
own, err := router.onUsersGetFullUser(viewerCtx, &tg.InputUserSelf{})
if err != nil {
t.Fatalf("get own full user: %v", err)
}
rating, ok := own.FullUser.GetStarsRating()
if !ok || rating.Level != domain.MaxAccountRatingLevel {
t.Fatalf("top-level rating = %+v (present=%v)", rating, ok)
}
if _, ok := rating.GetNextLevelStars(); ok {
t.Fatal("next_level_stars set at the maximum level")
}
botFull, err := router.onUsersGetFullUser(
viewerCtx,
&tg.InputUser{UserID: bot.ID, AccessHash: bot.AccessHash},
)
if err != nil {
t.Fatalf("get bot full user: %v", err)
}
if _, ok := botFull.FullUser.GetStarsRating(); ok {
t.Fatal("bot rating is visible")
}
official, err := router.onUsersGetFullUser(
viewerCtx,
&tg.InputUser{
UserID: domain.OfficialSystemUserID,
AccessHash: domain.OfficialSystemUser().AccessHash,
},
)
if err != nil {
t.Fatalf("get official system user: %v", err)
}
if _, ok := official.FullUser.GetStarsRating(); ok {
t.Fatal("system account rating is visible")
}
// One read for the ratable viewer and none for the bot/system guards.
if ratings.ratingCalls != 1 {
t.Fatalf("rating calls = %d, want 1", ratings.ratingCalls)
}
}

View file

@ -981,6 +981,16 @@ type UsernameRegistryService interface {
Collectible(ctx context.Context, username string) (domain.CollectibleUsername, error)
}
// AccountRatingService exposes the stored gramsrv composite rating used by the
// userFull rating projection.
//
// It is deliberately read-only at the RPC boundary: ratings are computed by the
// bounded background worker, while profile reads only fetch the latest stored
// projection. A nil service or a read failure leaves every rating flag unset.
type AccountRatingService interface {
Rating(ctx context.Context, userID int64) (domain.AccountRating, error)
}
// BotVerificationService is the third-party bot verification boundary
// (core.telegram.org/api/bots/verification): a verifier bot marking peers with its
// own icon and description, which official clients render as a badge distinct from
@ -1032,6 +1042,7 @@ type Deps struct {
Moderation ModerationService
Users UsersService
Usernames UsernameRegistryService
AccountRatings AccountRatingService
BotVerifications BotVerificationService
TelegramLogin TelegramLoginService
Updates UpdatesService

View file

@ -407,12 +407,58 @@ func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int6
full.SetBirthday(tgBirthday(u.Birthday))
}
}
r.applyAccountRatingToUserFull(ctx, currentUserID, u, &full)
// 个人频道account.updatePersonalChannel不在此落地它按 viewer 实时解析,作为缓存后的
// overlay 处理applyPersonalChannelToUserFull避免烤进 per-(viewer,target) 投影缓存以及
// build/chats 两次解析同一频道。
return full, nil
}
// applyAccountRatingToUserFull projects gramsrv's stored composite rating through
// the rating fields official clients already render. This is a gramsrv policy
// score, not a promise that its inputs or thresholds match Telegram's service.
//
// The projection is built inside the existing per-(viewer,target) UserFull
// cache. Therefore a cache miss adds at most one primary-key read and a cache hit
// adds none. Recompute and writes remain exclusively in the bounded background
// worker/admin paths.
func (r *Router) applyAccountRatingToUserFull(ctx context.Context, viewerUserID int64, target domain.User, full *tg.UserFull) {
targetUserID := target.ID
if r.deps.AccountRatings == nil || full == nil || !domain.RatableAccount(targetUserID, target.Bot) {
return
}
rating, err := r.deps.AccountRatings.Rating(ctx, targetUserID)
if err != nil {
// Missing, disabled and temporarily unavailable projections all preserve
// the legacy wire shape instead of failing the surrounding profile read.
return
}
full.SetStarsRating(tgAccountRatingLevel(rating.LevelSnapshot()))
if viewerUserID == 0 || viewerUserID != targetUserID {
return
}
pending, ok := rating.PendingLevel()
if !ok {
return
}
full.SetStarsMyPendingRating(tgAccountRatingLevel(pending))
full.SetStarsMyPendingRatingDate(int(rating.PendingDate.Unix()))
}
// tgAccountRatingLevel maps the local level snapshot onto starsRating#1b0e4f07.
// next_level_stars remains absent at the configured maximum local level.
func tgAccountRatingLevel(in domain.AccountRatingLevel) tg.StarsRating {
out := tg.StarsRating{
Level: in.Level,
CurrentLevelStars: in.CurrentLevelStars,
Stars: in.Stars,
}
if in.HasNextLevelStars {
out.SetNextLevelStars(in.NextLevelStars)
}
return out
}
// tgBirthday 把 domain 生日转 tg.BirthdayYear 可选0 表示不含年份)。
func tgBirthday(b domain.Birthday) tg.Birthday {
out := tg.Birthday{Day: b.Day, Month: b.Month}