fix: sync stars topup and paid reaction settings

This commit is contained in:
A 2026-07-08 01:19:22 +08:00
parent 4e18d31e16
commit 1f11b0fd1b
12 changed files with 506 additions and 24 deletions

View file

@ -16,6 +16,14 @@ import (
func starGiftInvalidErr() error { return tgerr.New(400, "STARGIFT_INVALID") }
func devStarsTopupOptions() []tg.StarsTopupOption {
return []tg.StarsTopupOption{
{Stars: 1000, Currency: "USD", Amount: 99},
{Stars: 2500, Currency: "USD", Amount: 199},
{Stars: 5000, Currency: "USD", Amount: 399},
}
}
// onPaymentsGetStarGifts 返回可购买礼物目录(hash 命中返回 NotModified)。
func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.PaymentsStarGiftsClass, error) {
if r.deps.Gifts == nil {
@ -41,13 +49,35 @@ func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.Payme
}, nil
}
// onPaymentsGetPaymentForm 仅处理 inputInvoiceStarGift:返回 paymentFormStarGift。
// onPaymentsGetPaymentForm 处理 Stars 专用 invoice:
// - inputInvoiceStarGift 返回 paymentFormStarGift。
// - inputInvoiceStars(inputStorePaymentStarsTopup) 返回 paymentFormStars。
//
// 崩溃约束:star gift invoice 必须返 paymentFormStarGift#b425cfe1(TDesktop 单分支 match),
// Invoice.Prices 必须非空(DrKLO/TDesktop 读 prices.front())。
// Stars 表单 Invoice.Prices 必须非空且 Currency=XTR(DrKLO/TDesktop 读 prices.front())。
func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsGetPaymentFormRequest) (tg.PaymentsPaymentFormClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
purpose, ok := starsTopupPurpose(inv)
if !ok {
return nil, notImplementedErr()
}
if r.deps.Stars == nil {
return nil, notImplementedErr()
}
if _, _, err := r.validateStarsTopupPurpose(ctx, userID, purpose); err != nil {
return nil, err
}
return r.starsTopupPaymentForm(userID, purpose), nil
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
return nil, notImplementedErr()
@ -55,10 +85,6 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
if r.deps.Gifts == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer); err != nil {
return nil, err
}
@ -75,21 +101,29 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
}, nil
}
// onPaymentsSendStarsForm 仅处理 inputInvoiceStarGift:Debit→投递/记账,
// 返回 paymentResult{updates}(含 updateStarsBalance;用户礼物还含私聊服务消息)。失败补偿退款。
// onPaymentsSendStarsForm 处理 star gift 与 Stars topup:
// - star gift: Debit→投递/记账,失败补偿退款。
// - topup: 校验测试包白名单→Credit 本地账本。
//
// 返回 paymentResult{updates}(含 updateStarsBalance;用户礼物还含私聊服务消息)。
// 崩溃约束:必须返回合法 paymentResult{非空 Updates}(DrKLO 强转)。
func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSendStarsFormRequest) (tg.PaymentsPaymentResultClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
return r.sendStarsTopupForm(ctx, userID, req.FormID, inv)
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
return nil, notImplementedErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer)
if err != nil {
return nil, err
@ -149,6 +183,83 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
return &tg.PaymentsPaymentResult{Updates: updates}, nil
}
func starsTopupPurpose(inv *tg.InputInvoiceStars) (*tg.InputStorePaymentStarsTopup, bool) {
if inv == nil {
return nil, false
}
purpose, ok := inv.Purpose.(*tg.InputStorePaymentStarsTopup)
return purpose, ok && purpose != nil
}
func (r *Router) validateStarsTopupPurpose(ctx context.Context, userID int64, purpose *tg.InputStorePaymentStarsTopup) (tg.StarsTopupOption, domain.Peer, error) {
if purpose == nil || purpose.Stars <= 0 || purpose.Amount <= 0 || purpose.Currency == "" {
return tg.StarsTopupOption{}, domain.Peer{}, starsAmountInvalidErr()
}
var matched tg.StarsTopupOption
found := false
for _, opt := range devStarsTopupOptions() {
if opt.Stars == purpose.Stars && opt.Currency == purpose.Currency && opt.Amount == purpose.Amount {
matched = opt
found = true
break
}
}
if !found {
return tg.StarsTopupOption{}, domain.Peer{}, starsFormAmountMismatchErr()
}
peer := domain.Peer{}
if purpose.SpendPurposePeer != nil {
var err error
peer, err = r.checkedDomainPeerFromInputPeer(ctx, userID, purpose.SpendPurposePeer)
if err != nil {
return tg.StarsTopupOption{}, domain.Peer{}, err
}
}
return matched, peer, nil
}
func (r *Router) starsTopupPaymentForm(userID int64, purpose *tg.InputStorePaymentStarsTopup) *tg.PaymentsPaymentFormStars {
return &tg.PaymentsPaymentFormStars{
FormID: starsTopupFormID(userID, purpose.Stars, purpose.Currency, purpose.Amount),
BotID: domain.OfficialSystemUserID,
Title: "Telegram Stars",
Description: "telesrv dev Stars top-up",
Invoice: tg.Invoice{
Currency: "XTR",
Prices: []tg.LabeledPrice{{Label: "Telegram Stars", Amount: purpose.Stars}},
},
Users: tgUsersForViewer(userID, []domain.User{domain.OfficialSystemUser()}),
}
}
func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStars) (tg.PaymentsPaymentResultClass, error) {
purpose, ok := starsTopupPurpose(inv)
if !ok {
return nil, notImplementedErr()
}
if formID == 0 {
return nil, formIDEmptyErr()
}
if r.deps.Stars == nil {
return nil, notImplementedErr()
}
_, peer, err := r.validateStarsTopupPurpose(ctx, userID, purpose)
if err != nil {
return nil, err
}
if formID != starsTopupFormID(userID, purpose.Stars, purpose.Currency, purpose.Amount) {
return nil, starsFormAmountMismatchErr()
}
if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil {
return nil, starsErr(err)
}
balance, err := r.deps.Stars.Credit(ctx, userID, purpose.Stars, domain.StarsReasonTopup, peer, "Stars top-up", "telesrv dev purchase")
if err != nil {
return nil, starsErr(err)
}
return &tg.PaymentsPaymentResult{Updates: starsBalanceUpdates(balance.Balance, r.clock.Now().Unix())}, nil
}
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string) (*tg.Updates, error) {
// 2. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message)
@ -668,6 +779,29 @@ func starGiftFormID(userID, giftID int64) int64 {
return id
}
func starsTopupFormID(userID, stars int64, currency string, amount int64) int64 {
id := userID*0x9e3779b1 ^ (stars << 7) ^ (amount << 13) ^ 0x5354415253
for _, ch := range currency {
id = id*131 + int64(ch)
}
if id < 0 {
id = ^id
}
if id == 0 {
id = 0x5354
}
return id
}
func starsBalanceUpdates(balance int64, unixDate int64) *tg.Updates {
return &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance}}},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: int(unixDate),
}
}
func giftPriceLabel(g domain.StarGift) string {
if g.Title != "" {
return g.Title