feat: sync monoforum and collectible emoji status

Sync telesrv bd15657 (feat(account): implement collectible emoji status).

Skipped telesrv docs changes per public sync rules.
This commit is contained in:
A 2026-07-19 20:37:10 +08:00
parent edb7057757
commit 0c99ae0a9d
91 changed files with 4061 additions and 693 deletions

View file

@ -12,14 +12,19 @@ import (
"telesrv/internal/store"
)
const paidMessageChannelCommissionPermille int64 = 850
// SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。
// 私信消息存进 channel_messages(复用 channel pts/事件/difference);发件权限(订阅者身份/管理员)
// 由 RPC 层校验,store 只校验 monoforum 频道存在,不要求发件人是成员(订阅者不是 monoforum 成员)。
// 私信消息存进 channel_messages(复用 channel pts/事件/difference);store 在写边界再次强制:订阅者
// 无需成员记录但只能写自己的 saved_peer,母频道管理员可以回复任意订阅者。
func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) {
if req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 ||
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" {
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" && req.Media == nil {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
if req.AllowPaidStars < 0 {
return domain.SendChannelMessageResult{}, domain.ErrStarsInvalidAmount
}
requestFingerprint, err := store.MonoforumSendFingerprint(req)
if err != nil {
return domain.SendChannelMessageResult{}, err
@ -65,6 +70,101 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
if channel.Deleted || !channel.Monoforum {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
parent, err := getChannelByID(ctx, tx, channel.LinkedMonoforumID)
if err != nil {
return domain.SendChannelMessageResult{}, err
}
var monoDeleted, parentDeleted, directEnabled bool
var linkedMonoforumID, monoPrice, parentPrice int64
if err := tx.QueryRow(ctx, `
SELECT m.deleted, p.deleted, p.broadcast_messages_allowed, p.linked_monoforum_id,
m.send_paid_messages_stars, p.send_paid_messages_stars
FROM channels m
JOIN channels p ON p.id = m.linked_monoforum_id
WHERE m.id = $1
FOR SHARE OF m, p`, channel.ID).Scan(
&monoDeleted, &parentDeleted, &directEnabled, &linkedMonoforumID, &monoPrice, &parentPrice,
); err != nil {
return domain.SendChannelMessageResult{}, err
}
if monoDeleted || parentDeleted || !directEnabled || linkedMonoforumID != channel.ID {
return domain.SendChannelMessageResult{}, domain.ErrChannelPrivate
}
if monoPrice != parentPrice || monoPrice < 0 {
return domain.SendChannelMessageResult{}, fmt.Errorf("monoforum %d paid-message price disagrees with parent %d", channel.ID, parent.ID)
}
channel.SendPaidMessagesStars = monoPrice
parent.SendPaidMessagesStars = parentPrice
parentMember, parentMemberErr := s.getChannelMember(ctx, tx, parent.ID, req.SenderUserID)
if parentMemberErr != nil && !errors.Is(parentMemberErr, domain.ErrChannelPrivate) {
return domain.SendChannelMessageResult{}, parentMemberErr
}
isAdmin := parentMemberErr == nil && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember)
if req.SenderUserID != req.SavedPeer.ID && !isAdmin {
return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired
}
var senderBalance *domain.StarsBalance
paidMessageStars := int64(0)
if !isAdmin && channel.SendPaidMessagesStars > 0 {
if req.AllowPaidStars < channel.SendPaidMessagesStars {
return domain.SendChannelMessageResult{}, &domain.StarsPaymentRequiredError{Stars: channel.SendPaidMessagesStars}
}
balance := domain.StarsBalance{UserID: req.SenderUserID}
if err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1 FOR UPDATE`, req.SenderUserID).
Scan(&balance.Balance, &balance.Granted); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient
}
return domain.SendChannelMessageResult{}, fmt.Errorf("lock paid-message sender balance: %w", err)
}
if balance.Balance < channel.SendPaidMessagesStars {
return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient
}
paidMessageStars = channel.SendPaidMessagesStars
if err := tx.QueryRow(ctx, `
UPDATE stars_balances
SET balance = balance - $2, updated_at = now()
WHERE user_id = $1
RETURNING balance`, req.SenderUserID, paidMessageStars).Scan(&balance.Balance); err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("debit paid-message sender balance: %w", err)
}
if err := insertStarsTxn(ctx, tx, req.SenderUserID, -paidMessageStars, domain.StarsReasonPaidMessage,
domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}, req.Date, "Paid message", ""); err != nil {
return domain.SendChannelMessageResult{}, err
}
channelCredit := paidMessageStars * paidMessageChannelCommissionPermille / 1000
if channelCredit > 0 {
if _, err := tx.Exec(ctx, `
INSERT INTO channel_stars_balances(channel_id, balance)
VALUES($1, $2)
ON CONFLICT(channel_id) DO UPDATE
SET balance = channel_stars_balances.balance + EXCLUDED.balance, updated_at = now()`, parent.ID, channelCredit); err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("credit paid-message channel balance: %w", err)
}
}
senderBalance = &balance
}
if req.ReplyTo != nil {
if req.ReplyTo.MessageID <= 0 || req.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}) {
return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid
}
var exists bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM channel_messages
WHERE channel_id = $1 AND id = $2 AND NOT deleted
AND saved_peer_type = $3 AND saved_peer_id = $4
)`, channel.ID, req.ReplyTo.MessageID, string(req.SavedPeer.Type), req.SavedPeer.ID).Scan(&exists); err != nil {
return domain.SendChannelMessageResult{}, err
}
if !exists {
return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid
}
}
from := domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
if isAdmin {
from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}
}
msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.MonoforumID)
if err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum message id: %w", err)
@ -74,16 +174,22 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum pts: %w", err)
}
msg := domain.ChannelMessage{
ChannelID: req.MonoforumID,
ID: msgID,
RandomID: req.RandomID,
SenderUserID: req.SenderUserID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID},
SavedPeer: req.SavedPeer,
Date: req.Date,
Body: req.Message,
Entities: append([]domain.MessageEntity(nil), req.Entities...),
Pts: pts,
ChannelID: req.MonoforumID,
ID: msgID,
RandomID: req.RandomID,
SenderUserID: req.SenderUserID,
From: from,
SavedPeer: req.SavedPeer,
SuggestedPost: req.SuggestedPost,
PaidMessageStars: paidMessageStars,
Date: req.Date,
Silent: req.Silent,
NoForwards: req.NoForwards,
Body: req.Message,
Entities: append([]domain.MessageEntity(nil), req.Entities...),
Media: req.Media,
ReplyTo: req.ReplyTo,
Pts: pts,
}
event := domain.ChannelUpdateEvent{
ChannelID: req.MonoforumID,
@ -130,13 +236,31 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, req.MonoforumID, msgID, pts); err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("update monoforum top: %w", err)
}
recipients := []int64{req.SavedPeer.ID}
rows, err := tx.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id = $1 AND status = 'active' AND role IN ('creator', 'admin') ORDER BY user_id`, parent.ID)
if err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("list monoforum recipients: %w", err)
}
for rows.Next() {
var recipient int64
if err := rows.Scan(&recipient); err != nil {
rows.Close()
return domain.SendChannelMessageResult{}, err
}
recipients = append(recipients, recipient)
}
if err := rows.Err(); err != nil {
rows.Close()
return domain.SendChannelMessageResult{}, err
}
rows.Close()
if err := tx.Commit(ctx); err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("commit send monoforum: %w", err)
}
committed = true
channel.TopMessageID = msgID
channel.Pts = pts
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event}, nil
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: uniqueChannelUserIDs(recipients, 0), SenderStarsBalance: senderBalance}, nil
}
// ListMonoforumHistory 拉取某订阅者(saved_peer)在 monoforum 内的私信历史,id 倒序分页。
@ -205,9 +329,11 @@ func (s *ChannelStore) ResolveMonoforumSend(ctx context.Context, viewerUserID, m
return domain.Channel{}, false, domain.ErrChannelInvalid
}
isAdmin := false
if _, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, mono.LinkedMonoforumID); err == nil {
if _, member, memberErr := s.getChannelForMember(ctx, s.db, viewerUserID, mono.LinkedMonoforumID); memberErr == nil {
isAdmin = member.Status == domain.ChannelMemberActive &&
(member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin)
} else if !errors.Is(memberErr, domain.ErrChannelPrivate) {
return domain.Channel{}, false, memberErr
}
return mono, isAdmin, nil
}