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:
parent
fff8de783a
commit
74c9249091
9 changed files with 362 additions and 31 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`. |
|
||||
|
|
|
|||
|
|
@ -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`。 |
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
272
internal/rpc/account_rating_projection_test.go
Normal file
272
internal/rpc/account_rating_projection_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue