chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -0,0 +1,635 @@
package postgres
import (
"context"
"crypto/rand"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"golang.org/x/sync/errgroup"
"sort"
"strings"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
"time"
)
// channelIDAtLeastAllocator 是 Redis 分配器的撞键自愈扩展:一次把计数器
// 顶到 floor 之上,避免按 1 步进追赶大空洞。
type channelIDAtLeastAllocator interface {
NextChannelIDAtLeast(ctx context.Context, floor int64) (int64, error)
}
// allocateFreshChannelID 分配未被占用的 channel id。计数器可能落后于
// channels 表真实最大 idRedis 快照回退、或测试 fallback 分配器绕过
// Redis 直写同一库),盲用会撞主键且可能污染后续 channel message id
// 分配;这里先点查预检,撞到就把分配器对账到表内最大 id 再取。
func (s *ChannelStore) allocateFreshChannelID(ctx context.Context) (int64, error) {
const maxAttempts = 4
channelID, err := s.ids.NextChannelID(ctx)
if err != nil {
return 0, fmt.Errorf("allocate channel id: %w", err)
}
for attempt := 0; attempt < maxAttempts; attempt++ {
var exists bool
if err := s.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM channels WHERE id = $1)`, channelID).Scan(&exists); err != nil {
return 0, fmt.Errorf("probe channel id %d: %w", channelID, err)
}
if !exists {
return channelID, nil
}
var maxID int64
if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channels`).Scan(&maxID); err != nil {
return 0, fmt.Errorf("load max channel id: %w", err)
}
if atLeast, ok := s.ids.(channelIDAtLeastAllocator); ok {
channelID, err = atLeast.NextChannelIDAtLeast(ctx, maxID)
} else {
channelID, err = s.ids.NextChannelID(ctx)
}
if err != nil {
return 0, fmt.Errorf("re-allocate channel id past %d: %w", maxID, err)
}
}
return 0, fmt.Errorf("allocate channel id: counter still colliding after %d attempts (id %d)", maxAttempts, channelID)
}
func (s *ChannelStore) CreateChannel(ctx context.Context, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) {
if req.CreatorUserID == 0 || strings.TrimSpace(req.Title) == "" {
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
}
if !req.Broadcast && !req.Megagroup {
req.Broadcast = true
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.CreateChannelResult{}, fmt.Errorf("create channel: db does not support transactions")
}
channelID, err := s.allocateFreshChannelID(ctx)
if err != nil {
return domain.CreateChannelResult{}, err
}
accessHash, err := randomChannelAccessHash()
if err != nil {
return domain.CreateChannelResult{}, err
}
inviteID, err := randomPositiveInt64()
if err != nil {
return domain.CreateChannelResult{}, err
}
inviteHash, err := randomInviteHash()
if err != nil {
return domain.CreateChannelResult{}, err
}
date := req.Date
if date == 0 {
date = nowUnix()
}
members := []domain.ChannelMember{creatorChannelMember(channelID, req.CreatorUserID, date)}
for _, userID := range uniqueChannelUserIDs(req.MemberUserIDs, req.CreatorUserID) {
members = append(members, domain.ChannelMember{
ChannelID: channelID,
UserID: userID,
InviterUserID: req.CreatorUserID,
Role: domain.ChannelRoleMember,
Status: domain.ChannelMemberActive,
JoinedAt: date,
})
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("begin create channel: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
msgID, err := s.msgIDs.NextChannelMessageID(ctx, channelID)
if err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("allocate channel message id: %w", err)
}
pts := 1
channel := domain.Channel{
ID: channelID,
AccessHash: accessHash,
CreatorUserID: req.CreatorUserID,
Title: strings.TrimSpace(req.Title),
About: req.About,
Broadcast: req.Broadcast,
Megagroup: req.Megagroup,
Forum: req.Forum,
ForumTabs: req.ForumTabs,
ParticipantsCount: len(members),
AdminsCount: 1,
HasLink: true,
TopMessageID: msgID,
Pts: pts,
TTLPeriod: req.TTLPeriod,
Date: date,
}
msg := domain.ChannelMessage{
ChannelID: channelID,
ID: msgID,
SenderUserID: req.CreatorUserID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: req.CreatorUserID},
Date: date,
Post: channel.Broadcast,
Action: &domain.ChannelMessageAction{Type: domain.ChannelActionCreate, Title: channel.Title},
Pts: pts,
}
event := domain.ChannelUpdateEvent{
ChannelID: channelID,
Type: domain.ChannelUpdateNewMessage,
Pts: pts,
PtsCount: 1,
Date: date,
Message: msg,
SenderUserID: req.CreatorUserID,
}
if err := insertChannelTx(ctx, tx, channel); err != nil {
return domain.CreateChannelResult{}, err
}
for _, member := range members {
if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil {
return domain.CreateChannelResult{}, err
}
}
if err := insertChannelMessageTx(ctx, tx, msg); err != nil {
return domain.CreateChannelResult{}, err
}
if err := insertChannelEventTx(ctx, tx, event); err != nil {
return domain.CreateChannelResult{}, err
}
for _, member := range members {
readMax := 0
if member.UserID == req.CreatorUserID {
readMax = msgID
}
if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, readMax, 0); err != nil {
return domain.CreateChannelResult{}, err
}
}
if err := insertChannelInviteTx(ctx, tx, domain.ChannelInvite{
ChannelID: channelID,
InviteID: inviteID,
Hash: inviteHash,
AdminUserID: req.CreatorUserID,
Permanent: true,
Date: date,
}); err != nil {
return domain.CreateChannelResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("commit create channel: %w", err)
}
committed = true
return domain.CreateChannelResult{
Channel: channel,
Members: append([]domain.ChannelMember(nil), members...),
Message: msg,
Event: event,
Recipients: channelMemberIDs(members),
}, nil
}
func (s *ChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) {
channel, member, preview, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID)
if err != nil {
return domain.ChannelView{}, err
}
if preview {
selfBoosts, err := s.countActiveUserBoostsForPeer(ctx, s.db, viewerUserID, domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, nowUnix())
if err != nil {
return domain.ChannelView{}, err
}
return domain.ChannelView{
Channel: channel,
Self: member,
Dialog: previewChannelDialog(viewerUserID, channel, member),
SelfBoostsApplied: selfBoosts,
}, nil
}
// getChannelDialog 与 countActiveUserBoostsForPeer 互不依赖,并发执行省一次串行往返。
// 两者都走 s.dbpgxpool并发安全各取独立连接各写自己的返回变量。
var (
dialog domain.ChannelDialog
selfBoosts int
exportedInvite *domain.ChannelInvite
)
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error {
var derr error
dialog, derr = s.getChannelDialog(gctx, s.db, viewerUserID, channel)
return derr
})
g.Go(func() error {
var berr error
selfBoosts, berr = s.countActiveUserBoostsForPeer(gctx, s.db, viewerUserID, domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, nowUnix())
return berr
})
if canExportChannelInvite(member) {
g.Go(func() error {
invite, found, ierr := s.getPermanentInviteForAdmin(gctx, s.db, channel.ID, viewerUserID)
if ierr != nil {
return ierr
}
if found {
exportedInvite = &invite
}
return nil
})
}
if err := g.Wait(); err != nil {
return domain.ChannelView{}, err
}
return domain.ChannelView{Channel: channel, Self: member, Dialog: dialog, SelfBoostsApplied: selfBoosts, ExportedInvite: exportedInvite}, nil
}
// ResolveChannel 是 GetChannel 的轻量版:只做访问校验并返回 Channel(含 access_hash)+Self
// 跳过 dialog top message / 读态 / boost 求和这 3 条额外 PG 查询。供 inputPeerFor 等只需
// access_hash / 频道标志的纯解析路径用——它们此前为拿一个 access_hash 付了完整 4 查询投影。
// 访问语义与 GetChannel 完全一致(私有非成员 ErrChannelPrivate、公开预览成员
func (s *ChannelStore) ResolveChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) {
channel, member, preview, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID)
if err != nil {
return domain.ChannelView{}, err
}
view := domain.ChannelView{Channel: channel, Self: member}
if preview {
// previewChannelDialog 是纯内存构造(无额外查询),保持与 GetChannel 预览态的 Dialog 一致。
view.Dialog = previewChannelDialog(viewerUserID, channel, member)
}
return view, nil
}
func (s *ChannelStore) GetChannels(ctx context.Context, viewerUserID int64, channelIDs []int64) ([]domain.ChannelView, error) {
if viewerUserID == 0 || len(channelIDs) == 0 {
return nil, nil
}
ids := uniqueNonZeroInt64s(channelIDs...)
if len(ids) == 0 {
return nil, nil
}
views := make(map[int64]domain.ChannelView, len(ids))
previewMembers := make(map[int64]domain.ChannelMember)
activeIDs := make([]int64, 0, len(ids))
rows, err := s.db.Query(ctx, `
SELECT `+channelColumns+`,
m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at,
m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts,
m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date
FROM channels c
JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1
WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
if err != nil {
return nil, fmt.Errorf("list channels for viewer: %w", err)
}
for rows.Next() {
channel, member, err := scanChannelWithMember(rows)
if err != nil {
rows.Close()
return nil, err
}
if err := validateChannelMemberVisible(member); err != nil {
if errors.Is(err, domain.ErrChannelUserBanned) {
views[channel.ID] = domain.ChannelView{Channel: channel, Self: member, Forbidden: true}
continue
}
if errors.Is(err, domain.ErrChannelPrivate) {
previewMembers[channel.ID] = member
continue
}
rows.Close()
return nil, err
}
views[channel.ID] = domain.ChannelView{Channel: channel, Self: member}
activeIDs = append(activeIDs, channel.ID)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("list channels for viewer: %w", err)
}
rows.Close()
dialogs, err := s.getChannelDialogs(ctx, s.db, viewerUserID, activeIDs)
if err != nil {
return nil, err
}
selfBoostsByChannelID, err := s.countActiveUserBoostsForChannels(ctx, s.db, viewerUserID, activeIDs, nowUnix())
if err != nil {
return nil, err
}
for id, dialog := range dialogs {
view := views[id]
view.Dialog = dialog
view.SelfBoostsApplied = selfBoostsByChannelID[id]
views[id] = view
}
remaining := make([]int64, 0, len(ids)-len(views))
for _, id := range ids {
if _, ok := views[id]; ok {
continue
}
remaining = append(remaining, id)
}
channels, err := listChannelsByIDs(ctx, s.db, remaining)
if err != nil {
return nil, err
}
for _, channel := range channels {
if member, _, ok, err := s.monoforumAdminPreview(ctx, s.db, viewerUserID, channel); err != nil {
return nil, err
} else if ok {
views[channel.ID] = domain.ChannelView{
Channel: channel,
Self: member,
Dialog: previewChannelDialog(viewerUserID, channel, member),
SelfBoostsApplied: 0,
}
continue
}
if !publicPreviewableChannel(channel) {
continue
}
existing, found := previewMembers[channel.ID]
member := publicPreviewMember(channel, viewerUserID, existing, found)
views[channel.ID] = domain.ChannelView{
Channel: channel,
Self: member,
Dialog: previewChannelDialog(viewerUserID, channel, member),
SelfBoostsApplied: 0,
}
}
out := make([]domain.ChannelView, 0, len(views))
for _, id := range ids {
if view, ok := views[id]; ok {
out = append(out, view)
}
}
return out, nil
}
func (s *ChannelStore) GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channelByID(ctx, s.db, channelID)
}
func insertChannelTx(ctx context.Context, tx pgx.Tx, ch domain.Channel) error {
rights, err := marshalJSON(ch.DefaultBannedRights, "{}")
if err != nil {
return err
}
reactions, err := marshalJSON(ch.ReactionPolicy, "{}")
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `
INSERT INTO channels (
id, access_hash, creator_user_id, title, about, username, verified, broadcast, megagroup, forum, forum_tabs,
autotranslation, restricted_sponsored, broadcast_messages_allowed, send_paid_messages_stars,
noforwards, join_to_send, join_request, signatures, pre_history_hidden, participants_hidden, antispam, linked_chat_id, slowmode_seconds, boosts_unrestrict, default_banned_rights, available_reactions,
color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, emoji_status_document_id, emoji_status_until,
participants_count, admins_count, kicked_count, banned_count, top_message_id, pinned_message_id, pts, ttl_period, date, deleted, monoforum, linked_monoforum_id
) VALUES ($1,$2,$3,$4,$5,NULLIF($6,''),$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,$41,$42,$43,$44,$45,$46,$47)`,
ch.ID, ch.AccessHash, ch.CreatorUserID, ch.Title, ch.About, ch.Username, ch.Verified, ch.Broadcast, ch.Megagroup, ch.Forum,
ch.ForumTabs, ch.Autotranslation, ch.RestrictedSponsored, ch.BroadcastMessagesAllowed, ch.SendPaidMessagesStars, ch.NoForwards, ch.JoinToSend, ch.JoinRequest, ch.Signatures, ch.PreHistoryHidden, ch.ParticipantsHidden, ch.AntiSpam, ch.LinkedChatID, ch.SlowmodeSeconds, ch.BoostsUnrestrict, rights, reactions,
ch.Color.HasColor, ch.Color.Color, ch.Color.BackgroundEmojiID, ch.ProfileColor.HasColor, ch.ProfileColor.Color, ch.ProfileColor.BackgroundEmojiID, ch.EmojiStatus.DocumentID, ch.EmojiStatus.Until,
ch.ParticipantsCount, ch.AdminsCount,
ch.KickedCount, ch.BannedCount, ch.TopMessageID, ch.PinnedMessageID, ch.Pts, ch.TTLPeriod, ch.Date, ch.Deleted, ch.Monoforum, ch.LinkedMonoforumID); err != nil {
return fmt.Errorf("insert channel: %w", err)
}
return nil
}
func scanChannel(row rowScanner) (domain.Channel, error) {
var ch domain.Channel
var rights, reactionPolicy string
var wallpaper *string
if err := row.Scan(channelScanDest(&ch, &rights, &reactionPolicy, &wallpaper)...); err != nil {
return domain.Channel{}, err
}
finishChannelScan(&ch, rights, reactionPolicy, wallpaper)
return ch, nil
}
func channelScanDest(ch *domain.Channel, rights, reactionPolicy *string, wallpaper **string) []any {
return []any{
&ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, &ch.Verified,
&ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.HasLink, &ch.LinkedChatID, &ch.Monoforum, &ch.LinkedMonoforumID, &ch.SlowmodeSeconds, &ch.BoostsUnrestrict, rights,
reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until,
wallpaper, &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID,
&ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted,
&ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped,
&ch.ActiveCallID, &ch.ActiveCallAccessHash, &ch.ActiveCallNotEmpty,
}
}
func finishChannelScan(ch *domain.Channel, rights, reactionPolicy string, wallpaper *string) {
_ = json.Unmarshal([]byte(rights), &ch.DefaultBannedRights)
_ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy)
if wallpaper != nil {
ch.Wallpaper = decodeJSONPtr[domain.Wallpaper](*wallpaper)
}
}
func publicPreviewableChannel(channel domain.Channel) bool {
return !channel.Deleted &&
(channel.Broadcast || channel.Megagroup) &&
strings.TrimSpace(channel.Username) != ""
}
func refreshChannelCountsTx(ctx context.Context, tx pgx.Tx, channel domain.Channel) (domain.Channel, error) {
var participants, admins, kicked, banned int
rows, err := tx.Query(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text,
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members
WHERE channel_id = $1`, channel.ID)
if err != nil {
return domain.Channel{}, fmt.Errorf("list channel members for counts: %w", err)
}
defer rows.Close()
for rows.Next() {
member, err := scanChannelMember(rows)
if err != nil {
return domain.Channel{}, err
}
if member.Status == domain.ChannelMemberKicked {
kicked++
}
if member.Status != domain.ChannelMemberActive {
continue
}
participants++
if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin {
admins++
}
if !zeroChannelBannedRights(member.BannedRights) {
banned++
}
}
if err := rows.Err(); err != nil {
return domain.Channel{}, err
}
if _, err := tx.Exec(ctx, `
UPDATE channels
SET participants_count = $2, admins_count = $3, kicked_count = $4, banned_count = $5, updated_at = now()
WHERE id = $1`, channel.ID, participants, admins, kicked, banned); err != nil {
return domain.Channel{}, fmt.Errorf("refresh channel counts: %w", err)
}
channel.ParticipantsCount = participants
channel.AdminsCount = admins
channel.KickedCount = kicked
channel.BannedCount = banned
return channel, nil
}
func addPeerRef(peer domain.Peer, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) {
switch peer.Type {
case domain.PeerTypeUser:
if peer.ID != 0 {
userRefs[peer.ID] = struct{}{}
}
case domain.PeerTypeChannel:
if peer.ID != 0 && peer.ID != currentChannelID {
channelRefs[peer.ID] = struct{}{}
}
}
}
func mapKeysInt64(items map[int64]struct{}) []int64 {
if len(items) == 0 {
return nil
}
out := make([]int64, 0, len(items))
for id := range items {
if id != 0 {
out = append(out, id)
}
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func uniqueChannelUserIDs(ids []int64, exclude int64) []int64 {
seen := make(map[int64]struct{}, len(ids))
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id == 0 || id == exclude {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func uniqueNonZeroInt64s(items ...int64) []int64 {
seen := make(map[int64]struct{}, len(items))
out := make([]int64, 0, len(items))
for _, item := range items {
if item == 0 {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
out = append(out, item)
}
return out
}
func marshalJSON(v any, empty string) ([]byte, error) {
if v == nil {
return []byte(empty), nil
}
raw, err := json.Marshal(v)
if err != nil {
return nil, err
}
if string(raw) == "null" {
return []byte(empty), nil
}
return raw, nil
}
func int64s(ids []int64) []int64 {
return append([]int64(nil), ids...)
}
// nonNullInt64s 返回非 nil 的 []int64 副本,供写入 NOT NULL bigint[] 列时用——nil 切片会被
// pgx 编码成 SQL NULL 违反 NOT NULL 约束(列的 DEFAULT '{}' 只在列被省略时生效,显式传参不触发)。
// 空列表写成 '{}' 而非 NULL读路径仍把空数组归一回 nil双 store 行为一致。
func nonNullInt64s(ids []int64) []int64 {
if len(ids) == 0 {
return []int64{}
}
return append([]int64(nil), ids...)
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func decodeJSONPtr[T any](raw string) *T {
if raw == "" || raw == "{}" || raw == "null" {
return nil
}
var out T
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return nil
}
return &out
}
func randomChannelAccessHash() (int64, error) {
return randomPositiveInt64()
}
func randomPositiveInt64() (int64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, fmt.Errorf("rand int64: %w", err)
}
return int64(binary.LittleEndian.Uint64(b[:]) & ((1 << 63) - 1)), nil
}
func nowUnix() int {
return int(time.Now().Unix())
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
func isRetryablePostgresTxError(err error) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
return pgErr.Code == "40P01" || pgErr.Code == "40001"
}
type pgChannelIDAllocator struct {
db sqlcgen.DBTX
}