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

@ -1030,9 +1030,9 @@ func run(logger *zap.Logger) error {
Store: accountService, Store: accountService,
Sender: loginEmailSender, Sender: loginEmailSender,
})) }))
// Collectible (NFT) usernames are projected at the protocol edge. The // Collectible (NFT) usernames and the gramsrv composite account rating are
// composite account rating is a separate local admin read model and is never // optional read models projected at the protocol edge. The rating worker
// projected into Telegram's stars_rating fields. // computes and persists scores; profile reads never recompute them.
collectibleUsernameStore := postgres.NewCollectibleUsernameStore(pool) collectibleUsernameStore := postgres.NewCollectibleUsernameStore(pool)
accountRatingStore := postgres.NewAccountRatingStore(pool) accountRatingStore := postgres.NewAccountRatingStore(pool)
usernamesService := usernamesapp.NewService( usernamesService := usernamesapp.NewService(
@ -1143,6 +1143,7 @@ func run(logger *zap.Logger) error {
Moderation: moderationService, Moderation: moderationService,
Users: usersService, Users: usersService,
Usernames: usernamesService, Usernames: usernamesService,
AccountRatings: ratingService,
BotVerifications: botVerificationService, BotVerifications: botVerificationService,
TelegramLogin: telegramLoginRPCDependency(telegramLoginService), TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
Updates: updatesService, Updates: updatesService,

View file

@ -1,7 +1,6 @@
-- Server-local composite account rating for moderation/operations in the admin -- Server-local composite account rating for gramsrv clients and moderation /
-- panel. This is intentionally not projected into Telegram's userFull -- operations. This uses gramsrv's own policy rather than claiming to reproduce
-- stars_rating/stars_my_pending_rating fields: those fields describe official -- Telegram's private rating algorithm.
-- Stars transaction-volume semantics, not this activity/moderation score.
-- --
-- account_rating is a derived read model: it can always be rebuilt from the -- account_rating is a derived read model: it can always be rebuilt from the
-- contributing sources (stars_transactions, message counts, moderation state) -- 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 -- component is kept separately so the admin panel can show why a level was
-- reached, and so recomputing one signal never silently discards another. -- reached, and so recomputing one signal never silently discards another.
-- --
-- 'stars' is the composite score used by the local admin model, not a wallet -- 'stars' is the composite score used by the local gramsrv model, not a wallet
-- balance and not an official Telegram Stars Rating value. -- balance.
CREATE TABLE public.account_rating ( CREATE TABLE public.account_rating (
user_id bigint PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE, user_id bigint PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
level integer NOT NULL DEFAULT 0 CHECK (level >= 0), level integer NOT NULL DEFAULT 0 CHECK (level >= 0),
stars bigint NOT NULL DEFAULT 0, stars bigint NOT NULL DEFAULT 0,
current_level_stars bigint NOT NULL DEFAULT 0 CHECK (current_level_stars >= 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), 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), CHECK (next_level_stars IS NULL OR next_level_stars > current_level_stars),
-- Signed contributions. penalty_component is stored as a non-negative -- Signed contributions. penalty_component is stored as a non-negative

View file

@ -544,15 +544,17 @@ path. `TELESRV_PUBLIC_BASE_URL` must resolve to that proxy for moderation freeze
### Composite account rating and collectible usernames ### Composite account rating and collectible usernames
The account rating is a server-local admin score combining Stars received and spent, bounded account activity, and The account rating is gramsrv's own local score combining Stars received and spent, bounded account activity, and
moderation penalties. It is intentionally **not** projected into Telegram's `userFull.stars_rating` or moderation penalties; it does not claim to reproduce Telegram's private algorithm 1:1. The stored level is projected
`stars_my_pending_rating`: those fields represent official Stars transaction-volume semantics, which this composite through `userFull.stars_rating`, while `stars_my_pending_rating` and its activation date are exposed only to the
does not implement. Every component is stored separately so operators can explain and reproduce a level. Collectible account itself so official clients can render the result without a patch. Profile reads only fetch a projection
(NFT) usernames are minted by the operator; no external marketplace, wallet or chain node is configured or contacted. 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 | | 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_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_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`. | | `TELESRV_RATING_RECOMPUTE_BATCH` | int / `500` | Stale projections recomputed per cycle; must be `1..10000`. |

View file

@ -519,11 +519,11 @@ active key。不要手工编辑 manifest 或 PEM,不要在各实例上分别
### 本地账号评分与 collectible username ### 本地账号评分与 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_PENDING_DELAY` | duration / `24h` | 本地评分上涨进入可见后台等级前的等待期;下降立即生效。允许 `0..720h`,`0` 表示立即应用。 |
| `TELESRV_RATING_RECOMPUTE_INTERVAL` | duration / `15m` | 后台重算周期,必须为正数。 | | `TELESRV_RATING_RECOMPUTE_INTERVAL` | duration / `15m` | 后台重算周期,必须为正数。 |
| `TELESRV_RATING_RECOMPUTE_BATCH` | int / `500` | 每轮重算的 stale projection 数,必须为 `1..10000`。 | | `TELESRV_RATING_RECOMPUTE_BATCH` | int / `500` | 每轮重算的 stale projection 数,必须为 `1..10000`。 |

View file

@ -2,9 +2,10 @@
// stored projection, recomputing it from the raw contribution signals, and // stored projection, recomputing it from the raw contribution signals, and
// applying operator adjustments through the contribution ledger. // applying operator adjustments through the contribution ledger.
// //
// This is an admin-only local model, not Telegram's Stars Rating protocol // This is gramsrv's local rating model, not a 1:1 reproduction of Telegram's
// surface. The service gathers signals, applies the configured weights and // private algorithm. The service gathers signals, applies the configured
// pending-delay policy, and persists the result under optimistic concurrency. // weights and pending-delay policy, and persists the result under optimistic
// concurrency for both admin and read-only client projection.
package rating package rating
import ( import (

View file

@ -7,14 +7,13 @@ import (
// Composite account rating. // Composite account rating.
// //
// This is a server-local moderation/operations score for the admin panel. It is // This is gramsrv's server-local account score. It deliberately uses its own
// deliberately not Telegram's Stars Rating: the official field describes Stars // inputs and thresholds (Stars, activity and moderation), rather than claiming
// transaction volume, whereas this model combines Stars, account activity and // to reproduce Telegram's private rating algorithm. The RPC edge exposes the
// moderation penalties. Projecting it into userFull.stars_rating would give // stored level through userFull's existing rating fields so official clients can
// official clients a materially false meaning, so the RPC edge keeps those // render it without a client patch.
// fields unset.
const ( const (
// MaxAccountRatingLevel bounds the local admin level. // MaxAccountRatingLevel bounds the local gramsrv level.
MaxAccountRatingLevel = 50 MaxAccountRatingLevel = 50
// accountRatingLevelUnit is the score required for level 1. Thresholds grow // accountRatingLevelUnit is the score required for level 1. Thresholds grow
// quadratically from it: level n needs accountRatingLevelUnit * n^2. // quadratically from it: level n needs accountRatingLevelUnit * n^2.
@ -83,7 +82,7 @@ type AccountRating struct {
Version int64 Version int64
} }
// AccountRatingLevel is the local admin-facing level snapshot. // AccountRatingLevel is the local client/admin-facing level snapshot.
type AccountRatingLevel struct { type AccountRatingLevel struct {
Level int Level int
CurrentLevelStars int64 CurrentLevelStars int64
@ -113,7 +112,7 @@ func RatableAccount(userID int64, bot bool) bool {
return userID > 0 && !bot && !IsSystemUserID(userID) 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 { func (r AccountRating) LevelSnapshot() AccountRatingLevel {
return AccountRatingLevel{ return AccountRatingLevel{
Level: r.Level, 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 // 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 // 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 { func ResolveAccountRatingPending(prev, computed AccountRating, delay time.Duration, now time.Time) AccountRating {
out := computed out := computed
out.Version = prev.Version + 1 out.Version = prev.Version + 1

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) 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 // BotVerificationService is the third-party bot verification boundary
// (core.telegram.org/api/bots/verification): a verifier bot marking peers with its // (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 // own icon and description, which official clients render as a badge distinct from
@ -1032,6 +1042,7 @@ type Deps struct {
Moderation ModerationService Moderation ModerationService
Users UsersService Users UsersService
Usernames UsernameRegistryService Usernames UsernameRegistryService
AccountRatings AccountRatingService
BotVerifications BotVerificationService BotVerifications BotVerificationService
TelegramLogin TelegramLoginService TelegramLogin TelegramLoginService
Updates UpdatesService Updates UpdatesService

View file

@ -407,12 +407,58 @@ func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int6
full.SetBirthday(tgBirthday(u.Birthday)) full.SetBirthday(tgBirthday(u.Birthday))
} }
} }
r.applyAccountRatingToUserFull(ctx, currentUserID, u, &full)
// 个人频道(account.updatePersonalChannel)不在此落地:它按 viewer 实时解析,作为缓存后的 // 个人频道(account.updatePersonalChannel)不在此落地:它按 viewer 实时解析,作为缓存后的
// overlay 处理(applyPersonalChannelToUserFull),避免烤进 per-(viewer,target) 投影缓存以及 // overlay 处理(applyPersonalChannelToUserFull),避免烤进 per-(viewer,target) 投影缓存以及
// build/chats 两次解析同一频道。 // build/chats 两次解析同一频道。
return full, nil 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 表示不含年份)。 // tgBirthday 把 domain 生日转 tg.Birthday(Year 可选,0 表示不含年份)。
func tgBirthday(b domain.Birthday) tg.Birthday { func tgBirthday(b domain.Birthday) tg.Birthday {
out := tg.Birthday{Day: b.Day, Month: b.Month} out := tg.Birthday{Day: b.Day, Month: b.Month}