diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index a25a32b6..16eefe26 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -1030,9 +1030,9 @@ func run(logger *zap.Logger) error { Store: accountService, Sender: loginEmailSender, })) - // Collectible (NFT) usernames are projected at the protocol edge. The - // composite account rating is a separate local admin read model and is never - // projected into Telegram's stars_rating fields. + // Collectible (NFT) usernames and the gramsrv composite account rating are + // optional read models projected at the protocol edge. The rating worker + // computes and persists scores; profile reads never recompute them. collectibleUsernameStore := postgres.NewCollectibleUsernameStore(pool) accountRatingStore := postgres.NewAccountRatingStore(pool) usernamesService := usernamesapp.NewService( @@ -1143,6 +1143,7 @@ func run(logger *zap.Logger) error { Moderation: moderationService, Users: usersService, Usernames: usernamesService, + AccountRatings: ratingService, BotVerifications: botVerificationService, TelegramLogin: telegramLoginRPCDependency(telegramLoginService), Updates: updatesService, diff --git a/deploy/migrations/0152_account_rating.up.sql b/deploy/migrations/0152_account_rating.up.sql index 09947eac..7e2499e2 100644 --- a/deploy/migrations/0152_account_rating.up.sql +++ b/deploy/migrations/0152_account_rating.up.sql @@ -1,7 +1,6 @@ --- Server-local composite account rating for moderation/operations in the admin --- panel. This is intentionally not projected into Telegram's userFull --- stars_rating/stars_my_pending_rating fields: those fields describe official --- Stars transaction-volume semantics, not this activity/moderation score. +-- Server-local composite account rating for gramsrv clients and moderation / +-- operations. This uses gramsrv's own policy rather than claiming to reproduce +-- Telegram's private rating algorithm. -- -- account_rating is a derived read model: it can always be rebuilt from the -- contributing sources (stars_transactions, message counts, moderation state) @@ -9,15 +8,15 @@ -- component is kept separately so the admin panel can show why a level was -- reached, and so recomputing one signal never silently discards another. -- --- 'stars' is the composite score used by the local admin model, not a wallet --- balance and not an official Telegram Stars Rating value. +-- 'stars' is the composite score used by the local gramsrv model, not a wallet +-- balance. CREATE TABLE public.account_rating ( user_id bigint PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE, level integer NOT NULL DEFAULT 0 CHECK (level >= 0), stars bigint NOT NULL DEFAULT 0, current_level_stars bigint NOT NULL DEFAULT 0 CHECK (current_level_stars >= 0), - -- NULL means the top local admin level has been reached. + -- NULL means the top local gramsrv level has been reached. next_level_stars bigint CHECK (next_level_stars IS NULL OR next_level_stars > 0), CHECK (next_level_stars IS NULL OR next_level_stars > current_level_stars), -- Signed contributions. penalty_component is stored as a non-negative diff --git a/docs/configuration.en.md b/docs/configuration.en.md index 62f49dce..fdeb548a 100644 --- a/docs/configuration.en.md +++ b/docs/configuration.en.md @@ -544,15 +544,17 @@ path. `TELESRV_PUBLIC_BASE_URL` must resolve to that proxy for moderation freeze ### Composite account rating and collectible usernames -The account rating is a server-local admin score combining Stars received and spent, bounded account activity, and -moderation penalties. It is intentionally **not** projected into Telegram's `userFull.stars_rating` or -`stars_my_pending_rating`: those fields represent official Stars transaction-volume semantics, which this composite -does not implement. Every component is stored separately so operators can explain and reproduce a level. Collectible -(NFT) usernames are minted by the operator; no external marketplace, wallet or chain node is configured or contacted. +The account rating is gramsrv's own local score combining Stars received and spent, bounded account activity, and +moderation penalties; it does not claim to reproduce Telegram's private algorithm 1:1. The stored level is projected +through `userFull.stars_rating`, while `stars_my_pending_rating` and its activation date are exposed only to the +account itself so official clients can render the result without a patch. Profile reads only fetch a projection +already persisted by the background worker and reuse the existing 30-minute `userFull` projection cache; they never +recompute or write a rating synchronously. Every component remains separately explainable. Collectible (NFT) +usernames are minted by the operator; no external marketplace, wallet or chain node is configured or contacted. | Setting | Type / code default | Description and constraints | |---|---|---| -| `TELESRV_RATING_ENABLED` | bool / `true` | Enables the local admin composite rating. Disabled refuses rating writes; client-facing Telegram rating fields remain unset in either mode. | +| `TELESRV_RATING_ENABLED` | bool / `true` | Enables the local composite rating and client level projection. Disabled refuses rating writes and leaves client rating flags unset. | | `TELESRV_RATING_PENDING_DELAY` | duration / `24h` | How long a local rating increase stays pending before it becomes the visible admin level. A decrease is always applied immediately, so a penalty is never delayed. `0` applies every change at once; must be `0..720h`. | | `TELESRV_RATING_RECOMPUTE_INTERVAL` | duration / `15m` | Background recompute worker interval; must be positive. | | `TELESRV_RATING_RECOMPUTE_BATCH` | int / `500` | Stale projections recomputed per cycle; must be `1..10000`. | diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index df4a54c1..e05b9e9e 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -519,11 +519,11 @@ active key。不要手工编辑 manifest 或 PEM,不要在各实例上分别 ### 本地账号评分与 collectible username -账号评分是供管理后台使用的本地风控/信誉复合分,组合 Stars 收支、账号活跃和管理处罚。它**不会**投影到 Telegram 的 `userFull.stars_rating` / `stars_my_pending_rating`:官方字段表达 Stars 交易量,当前复合公式不具备同等语义。Collectible username 由管理员签发,本功能不访问外部市场、钱包或区块链节点。 +账号评分是 gramsrv 自己的本地风控/信誉复合分,组合 Stars 收支、账号活跃和管理处罚;它不承诺 1:1 复刻 Telegram 的私有评分算法。已计算的本地等级会投影到 `userFull.stars_rating`,本人还会收到 `stars_my_pending_rating` 与生效时间,让官方客户端直接显示;他人的 pending 永不下发。资料页只读取后台 worker 已持久化的评分并复用现有 30 分钟 `userFull` 投影缓存,不会同步重算或写库。Collectible username 由管理员签发,本功能不访问外部市场、钱包或区块链节点。 | 参数 | 类型 / 代码默认值 | 说明与约束 | |---|---|---| -| `TELESRV_RATING_ENABLED` | bool / `true` | 启用本地后台复合评分;关闭时拒绝评分写入,两种模式都不设置客户端官方 Stars Rating 字段。 | +| `TELESRV_RATING_ENABLED` | bool / `true` | 启用本地复合评分及客户端等级投影;关闭时拒绝评分写入且客户端 rating flags 保持未设置。 | | `TELESRV_RATING_PENDING_DELAY` | duration / `24h` | 本地评分上涨进入可见后台等级前的等待期;下降立即生效。允许 `0..720h`,`0` 表示立即应用。 | | `TELESRV_RATING_RECOMPUTE_INTERVAL` | duration / `15m` | 后台重算周期,必须为正数。 | | `TELESRV_RATING_RECOMPUTE_BATCH` | int / `500` | 每轮重算的 stale projection 数,必须为 `1..10000`。 | diff --git a/internal/app/rating/service.go b/internal/app/rating/service.go index 0b2c4b79..2c946723 100644 --- a/internal/app/rating/service.go +++ b/internal/app/rating/service.go @@ -2,9 +2,10 @@ // stored projection, recomputing it from the raw contribution signals, and // applying operator adjustments through the contribution ledger. // -// This is an admin-only local model, not Telegram's Stars Rating protocol -// surface. The service gathers signals, applies the configured weights and -// pending-delay policy, and persists the result under optimistic concurrency. +// This is gramsrv's local rating model, not a 1:1 reproduction of Telegram's +// private algorithm. The service gathers signals, applies the configured +// weights and pending-delay policy, and persists the result under optimistic +// concurrency for both admin and read-only client projection. package rating import ( diff --git a/internal/domain/account_rating.go b/internal/domain/account_rating.go index 40fb5dde..c583034d 100644 --- a/internal/domain/account_rating.go +++ b/internal/domain/account_rating.go @@ -7,14 +7,13 @@ import ( // Composite account rating. // -// This is a server-local moderation/operations score for the admin panel. It is -// deliberately not Telegram's Stars Rating: the official field describes Stars -// transaction volume, whereas this model combines Stars, account activity and -// moderation penalties. Projecting it into userFull.stars_rating would give -// official clients a materially false meaning, so the RPC edge keeps those -// fields unset. +// This is gramsrv's server-local account score. It deliberately uses its own +// inputs and thresholds (Stars, activity and moderation), rather than claiming +// to reproduce Telegram's private rating algorithm. The RPC edge exposes the +// stored level through userFull's existing rating fields so official clients can +// render it without a client patch. const ( - // MaxAccountRatingLevel bounds the local admin level. + // MaxAccountRatingLevel bounds the local gramsrv level. MaxAccountRatingLevel = 50 // accountRatingLevelUnit is the score required for level 1. Thresholds grow // quadratically from it: level n needs accountRatingLevelUnit * n^2. @@ -83,7 +82,7 @@ type AccountRating struct { Version int64 } -// AccountRatingLevel is the local admin-facing level snapshot. +// AccountRatingLevel is the local client/admin-facing level snapshot. type AccountRatingLevel struct { Level int CurrentLevelStars int64 @@ -113,7 +112,7 @@ func RatableAccount(userID int64, bot bool) bool { return userID > 0 && !bot && !IsSystemUserID(userID) } -// LevelSnapshot returns the current local admin-facing level. +// LevelSnapshot returns the current visible local level. func (r AccountRating) LevelSnapshot() AccountRatingLevel { return AccountRatingLevel{ Level: r.Level, @@ -303,7 +302,7 @@ func AccountRatingLevelForStars(stars int64) (level int, currentLevelStars int64 // // A score that dropped is applied at once -- a penalty must not sit behind a // delay. A score that grew is parked until delay has elapsed; once the parked -// window has passed the pending delta is folded into the visible admin rating. +// window has passed the pending delta is folded into the visible local rating. func ResolveAccountRatingPending(prev, computed AccountRating, delay time.Duration, now time.Time) AccountRating { out := computed out.Version = prev.Version + 1 diff --git a/internal/rpc/account_rating_projection_test.go b/internal/rpc/account_rating_projection_test.go new file mode 100644 index 00000000..d91f124b --- /dev/null +++ b/internal/rpc/account_rating_projection_test.go @@ -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) + } +} diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 44a37643..1a885e68 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -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 diff --git a/internal/rpc/users.go b/internal/rpc/users.go index 088def88..d0e58866 100644 --- a/internal/rpc/users.go +++ b/internal/rpc/users.go @@ -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.Birthday(Year 可选,0 表示不含年份)。 func tgBirthday(b domain.Birthday) tg.Birthday { out := tg.Birthday{Day: b.Day, Month: b.Month}