Files
bd2/go/internal/server/commerce/service.go
T
Flechazo d8771caef2 feat(all): add hunting and events, commerce and rewards, binary calendars, GameData caching and login diagnostics
Implement hunting AP and progress, event tasks, exchanges, games, stories and actions; connect battle and reward handling.

Add CashShop plugin and installation, server purchases and entitlements, recharge rules and plugin version metadata.

Distribute binary calendars independently; cache GameData in memory and fix single-connection query deadlocks.

Improve automatic login and recovery diagnostics; document configuration and release flows.
2026-10-05 11:50:31 +08:00

696 lines
21 KiB
Go

package commerce
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"sync"
"time"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/readonly"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
// Economy and the receipt store must participate in the caller's account
// transaction. A failed hook, delegate or save then rolls back all purchases.
type Economy interface {
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
}
type CountProvider interface{ PurchaseCountDBInfos() [][]byte }
type PurchaseDelegate func(gamedata.CashProductKey, []byte) ([]byte, bool, error)
type PurchaseHook func(string, gamedata.CashProductDesign, uint64) error
type purchaseReceipt struct {
Digest string `json:"digest"`
Response []byte `json:"response"`
}
type purchaseCount struct {
Count uint64 `json:"count"`
Period string `json:"period"`
Lifetime uint64 `json:"lifetime"`
}
type purchaseState struct {
Receipts map[string]purchaseReceipt `json:"receipts"`
Billing map[string]string `json:"billing"`
Counts map[string]purchaseCount `json:"counts"`
}
type Service struct {
mu sync.Mutex
catalog *Catalog
store stateio.Store
economy Economy
now func() time.Time
resetSeconds int64
delegate PurchaseDelegate
hook PurchaseHook
legacy CountProvider
shopProducts [][]byte
shopWindows map[gamedata.CashProductKey][2]uint64
predecessors map[gamedata.CashProductKey][]gamedata.CashProductKey
}
func NewService(catalog *Catalog, store stateio.Store, economy Economy) (*Service, error) {
if catalog == nil || store == nil || economy == nil {
return nil, fmt.Errorf("commerce: invalid service dependencies")
}
s := &Service{catalog: catalog, store: store, economy: economy, now: time.Now}
_, err := s.load()
return s, err
}
func (s *Service) AttachDelegate(d PurchaseDelegate) { s.delegate = d }
func (s *Service) AttachPurchaseHook(h PurchaseHook) { s.hook = h }
func (s *Service) AttachLegacyCounts(p CountProvider) { s.legacy = p }
// AttachPackageRules enforces the client's type-2/type-8 step and relay
// ordering using versioned contentsGroupId/contentsSortId, never SKU numbers.
func (s *Service) AttachPackageRules(packages []gamedata.CashPackageDesign) error {
rules := map[gamedata.CashProductKey][]gamedata.CashProductKey{}
for _, p := range packages {
if (p.PackageType != 2 && p.PackageType != 8) || p.ContentsGroupID == 0 || p.ContentsSortID <= 1 {
continue
}
key := gamedata.CashProductKey{GroupID: p.GroupID, ProductID: p.ID, SaleGroup: p.SaleGroup}
var prior []gamedata.CashProductKey
for _, q := range packages {
if q.PackageType == p.PackageType && q.ContentsGroupID == p.ContentsGroupID && q.ContentsSortID == p.ContentsSortID-1 {
prior = append(prior, gamedata.CashProductKey{GroupID: q.GroupID, ProductID: q.ID, SaleGroup: q.SaleGroup})
}
}
// Some current-version packages have a sort label above one but an
// independent contents group. Only a real same-group predecessor is a
// progression rule; do not invent links between adjacent product IDs.
if len(prior) == 0 {
continue
}
rules[key] = prior
}
s.predecessors = rules
return nil
}
func (s *Service) predecessorBought(v purchaseState, key gamedata.CashProductKey) bool {
prior := s.predecessors[key]
if len(prior) == 0 {
return true
}
for _, p := range prior {
if v.Counts[skuKey(p)].Lifetime > 0 {
return true
}
}
if s.legacy != nil {
for _, raw := range s.legacy.PurchaseCountDBInfos() {
g, _, _ := wire.Varint(raw, 1)
id, _, _ := wire.Varint(raw, 2)
sale, _, _ := wire.Varint(raw, 3)
n, _, _ := wire.Varint(raw, 4)
if n > 0 {
for _, p := range prior {
if p.GroupID == g && p.ProductID == id && p.SaleGroup == sale {
return true
}
}
}
}
}
return false
}
// AttachShopSeed retains versioned dynamic windows and event identities. It
// must run before serving sessions; absent windows never authorize timed goods.
func (s *Service) AttachShopSeed(seed *readonly.Seed) error {
if seed == nil {
return fmt.Errorf("commerce: missing shop seed")
}
_, raw, handled, err := seed.Handle("/CashShopInfo", wire.AppendVarint(nil, 1, 1))
if err != nil || !handled {
return fmt.Errorf("commerce: invalid shop seed: %w", err)
}
windows := map[gamedata.CashProductKey][2]uint64{}
var products [][]byte
err = wire.Walk(raw, func(f wire.Field) error {
if f.Number != 1 {
return nil
}
if f.Type != 2 {
return fmt.Errorf("commerce: invalid shop product")
}
g, _, e := wire.Varint(f.Value, 1)
if e != nil {
return e
}
id, _, e := wire.Varint(f.Value, 2)
if e != nil {
return e
}
sale, _, e := wire.Varint(f.Value, 3)
if e != nil {
return e
}
start, _, e := wire.Varint(f.Value, 4)
if e != nil {
return e
}
end, _, e := wire.Varint(f.Value, 5)
if e != nil {
return e
}
k := gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale}
if _, ok := windows[k]; ok {
return fmt.Errorf("commerce: duplicate shop schedule")
}
if end != 0 && start >= end {
return fmt.Errorf("commerce: invalid shop window")
}
windows[k] = [2]uint64{start, end}
products = append(products, append([]byte(nil), f.Value...))
return nil
})
if err != nil {
return err
}
s.shopProducts = products
s.shopWindows = windows
return nil
}
func (s *Service) available(d gamedata.CashProductDesign) bool {
w, ok := s.shopWindows[d.Key]
if !ok {
return d.TimeLimitType == 0
}
now := uint64(s.now().UnixMilli())
return w[0] <= now && (w[1] == 0 || now < w[1])
}
func (s *Service) IsAvailable(key gamedata.CashProductKey) bool {
d, ok := s.catalog.Design(key)
return ok && s.available(d)
}
func (s *Service) shopInfo() []byte {
var response []byte
for _, raw := range s.shopProducts {
g, _, _ := wire.Varint(raw, 1)
id, _, _ := wire.Varint(raw, 2)
sale, _, _ := wire.Varint(raw, 3)
d, ok := s.catalog.Design(gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale})
if ok && s.available(d) {
response = wire.AppendBytes(response, 1, raw)
}
}
t := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second)
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
daily := day.AddDate(0, 0, 1)
days := (8 - int(day.Weekday())) % 7
if days == 0 {
days = 7
}
weekly := day.AddDate(0, 0, days)
monthly := time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, time.UTC)
for i, reset := range []time.Time{daily, weekly, monthly} {
response = wire.AppendVarint(response, i+2, uint64(reset.Add(time.Duration(s.resetSeconds)*time.Second).UnixMilli()))
}
return response
}
func (s *Service) SetClock(now func() time.Time, resetSeconds int64) {
s.now = now
s.resetSeconds = resetSeconds
}
func skuKey(k gamedata.CashProductKey) string {
return fmt.Sprintf("%d:%d:%d", k.GroupID, k.ProductID, k.SaleGroup)
}
func (s *Service) load() (purchaseState, error) {
v := purchaseState{Receipts: map[string]purchaseReceipt{}, Billing: map[string]string{}, Counts: map[string]purchaseCount{}}
b, e := s.store.Load("commerce")
if e != nil || b == nil {
return v, e
}
if e = stateio.RequireExactJSONObject(b, "receipts", "billing", "counts"); e != nil {
return v, e
}
if e = json.Unmarshal(b, &v); e != nil {
return v, e
}
if v.Receipts == nil || v.Billing == nil || v.Counts == nil {
return v, fmt.Errorf("commerce: invalid state")
}
for identity, receipt := range v.Receipts {
digest, err := hex.DecodeString(receipt.Digest)
if identity == "" || err != nil || len(digest) != sha256.Size || len(receipt.Response) == 0 {
return v, fmt.Errorf("commerce: invalid saved receipt")
}
}
for _, identity := range v.Billing {
if _, ok := v.Receipts[identity]; !ok {
return v, fmt.Errorf("commerce: billing identity has no purchase receipt")
}
}
for key, count := range v.Counts {
if key == "" || count.Period == "" || count.Count == 0 || count.Count > math.MaxInt32 || count.Lifetime < count.Count {
return v, fmt.Errorf("commerce: invalid saved purchase count")
}
}
return v, nil
}
func (s *Service) period(typ uint64) string {
t := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second)
switch typ {
case 1:
return t.Format("2006-01-02")
case 2:
y, w := t.ISOWeek()
return fmt.Sprintf("%d-W%02d", y, w)
case 3:
return t.Format("2006-01")
default:
return "account"
}
}
func (s *Service) count(v purchaseState, d gamedata.CashProductDesign) uint64 {
c := v.Counts[skuKey(d.Key)]
if c.Period != s.period(d.PurchaseLimitType) {
return 0
}
return c.Count
}
func (s *Service) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) {
switch path {
case "/CashShopInfo":
if s.shopWindows == nil {
return 0, nil, false, nil
}
if seq, ok, err := wire.Varint(request, 1); err != nil || !ok || seq == 0 || seq > math.MaxInt32 {
return 60, nil, true, fmt.Errorf("commerce: invalid shop sequence")
}
return 60, s.shopInfo(), true, nil
case "/CashShopPurchaseCountInfo":
if seq, ok, err := wire.Varint(request, 1); err != nil || !ok || seq == 0 || seq > math.MaxInt32 {
return 432, nil, true, fmt.Errorf("commerce: invalid count sequence")
}
infos, err := s.purchaseCountDBInfos()
if err != nil {
return 0, nil, true, err
}
var b []byte
for _, i := range infos {
b = wire.AppendBytes(b, 1, i)
}
return 432, b, true, nil
case "/CashShopBuy":
return s.buy(session, request)
default:
return 0, nil, false, nil
}
}
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
return s.HandleSession(path, request, "")
}
type buyLine struct {
Key gamedata.CashProductKey
Count uint64
}
func parseBuy(request []byte) (uint64, []buyLine, []string, error) {
seq, found, e := wire.Varint(request, 1)
if e != nil || !found || seq == 0 || seq > math.MaxInt32 {
return 0, nil, nil, fmt.Errorf("commerce: invalid sequence")
}
group, found, e := wire.Varint(request, 3)
if e != nil || !found || group == 0 {
return 0, nil, nil, fmt.Errorf("commerce: invalid product group")
}
var lines []buyLine
var billing []string
seen := map[gamedata.CashProductKey]bool{}
e = wire.Walk(request, func(f wire.Field) error {
switch f.Number {
case 4:
if f.Type != 2 {
return fmt.Errorf("commerce: invalid buy info")
}
id, ok, err := wire.Varint(f.Value, 1)
if err != nil || !ok || id == 0 {
return fmt.Errorf("commerce: invalid product id")
}
sale, _, err := wire.Varint(f.Value, 2)
if err != nil {
return err
}
n, ok, err := wire.Varint(f.Value, 3)
if err != nil || !ok || n == 0 || n > math.MaxInt32 {
return fmt.Errorf("commerce: invalid buy count")
}
k := gamedata.CashProductKey{GroupID: group, ProductID: id, SaleGroup: sale}
if seen[k] {
return fmt.Errorf("commerce: duplicate buy info")
}
seen[k] = true
lines = append(lines, buyLine{k, n})
case 7:
if f.Type != 2 {
return fmt.Errorf("commerce: invalid billing info")
}
return wire.Walk(f.Value, func(b wire.Field) error {
if b.Number == 1 || b.Number == 2 {
if b.Type != 2 {
return fmt.Errorf("commerce: invalid billing identity")
}
if len(b.Value) > 16384 {
return fmt.Errorf("commerce: excessive billing identity")
}
if len(b.Value) > 0 {
h := sha256.Sum256(b.Value)
billing = append(billing, fmt.Sprintf("%d:%x", b.Number, h))
}
}
return nil
})
}
return nil
})
if e == nil && len(lines) == 0 {
e = fmt.Errorf("commerce: empty purchase")
}
return seq, lines, billing, e
}
func (s *Service) buy(session string, request []byte) (int, []byte, bool, error) {
if session == "" {
return 61, nil, true, fmt.Errorf("commerce: authenticated session required")
}
seq, lines, billing, e := parseBuy(request)
if e != nil {
return 61, nil, true, e
}
var native, cash bool
for _, line := range lines {
if design, ok := s.catalog.Design(line.Key); ok {
if design.PriceType == 1 {
cash = true
} else {
native = true
}
}
}
if native && !cash {
return 0, nil, false, nil
}
if native {
return 61, nil, true, fmt.Errorf("commerce: mixed cash and native purchase")
}
h := sha256.Sum256(request)
digest := hex.EncodeToString(h[:])
sessionHash := sha256.Sum256([]byte(session))
identity := fmt.Sprintf("commerce:%x:%d", sessionHash, seq)
s.mu.Lock()
defer s.mu.Unlock()
v, e := s.load()
if e != nil {
return 61, nil, true, e
}
if r, ok := v.Receipts[identity]; ok {
if r.Digest != digest {
return 61, nil, true, fmt.Errorf("commerce: sequence reused with different purchase")
}
return 61, append([]byte(nil), r.Response...), true, nil
}
for _, b := range billing {
if _, ok := v.Billing[b]; ok {
return 61, nil, true, fmt.Errorf("commerce: billing receipt already used")
}
}
// Validate every line before charging the first one.
designs := make([]gamedata.CashProductDesign, len(lines))
quotes := make([]Product, len(lines))
for i, l := range lines {
if !s.predecessorBought(v, l.Key) {
return 61, nil, true, fmt.Errorf("commerce: preceding package purchase required")
}
d, ok := s.catalog.Design(l.Key)
if !ok {
return 61, nil, true, fmt.Errorf("commerce: unknown product")
}
if !s.available(d) {
return 61, nil, true, fmt.Errorf("commerce: product is not currently available")
}
q, err := s.catalog.Quote(l.Key, l.Count)
if err != nil {
return 61, nil, true, err
}
if !q.Enabled {
return 61, nil, true, fmt.Errorf("commerce: product disabled")
}
if l.Count > 1 && d.BulkOrderAvailability == 0 {
return 61, nil, true, fmt.Errorf("commerce: bulk purchase disabled")
}
n := s.count(v, d)
if s.legacy != nil {
for _, raw := range s.legacy.PurchaseCountDBInfos() {
g, _, _ := wire.Varint(raw, 1)
id, _, _ := wire.Varint(raw, 2)
sale, _, _ := wire.Varint(raw, 3)
legacyCount, _, _ := wire.Varint(raw, 4)
if g == l.Key.GroupID && id == l.Key.ProductID && sale == l.Key.SaleGroup && legacyCount > n {
n = legacyCount
}
}
}
if d.PurchaseLimitType > 4 {
return 61, nil, true, fmt.Errorf("commerce: unknown purchase limit")
}
if n > math.MaxInt32-l.Count {
return 61, nil, true, fmt.Errorf("commerce: purchase count overflow")
}
if d.PurchaseLimitType != 0 && (d.PurchaseLimitCount == 0 || n+l.Count > d.PurchaseLimitCount) {
return 61, nil, true, fmt.Errorf("commerce: purchase limit exceeded")
}
designs[i] = d
quotes[i] = q
}
if e = validateAcceptedQuote(request, quotes); e != nil {
return 61, nil, true, e
}
var bundle []byte
for i, l := range lines {
d, q := designs[i], quotes[i]
operation := fmt.Sprintf("%s:%d", identity, i)
var costs []gamedata.Reward
if q.Cost > 0 {
var typ uint64
switch q.Currency {
case "paid_diamonds":
typ = 2
case "diamonds":
typ = 3
case "gold":
typ = 4
default:
return 61, nil, true, fmt.Errorf("commerce: unknown currency %q", q.Currency)
}
costs = []gamedata.Reward{{Type: typ, Count: q.Cost}}
}
var special []byte
handled := false
// Delegate must only select known special products. Parent account transaction
// guarantees its grant and the subsequent debit commit together.
if s.delegate != nil {
special, handled, e = s.delegate(l.Key, request)
if e != nil {
return 61, nil, true, e
}
if handled && (len(lines) != 1 || l.Count != 1) {
return 61, nil, true, fmt.Errorf("commerce: special purchase requires one product")
}
}
var rewards []gamedata.Reward
if !handled {
if d.RandomBoxID == 0 {
return 61, nil, true, fmt.Errorf("commerce: product reward missing")
}
rewards = append(rewards, gamedata.Reward{Type: 9, ID: d.RandomBoxID, Count: l.Count})
if d.BonusRandomBoxID != 0 {
rewards = append(rewards, gamedata.Reward{Type: 9, ID: d.BonusRandomBoxID, Count: l.Count})
}
}
b, err := s.economy.Apply(operation, costs, rewards)
if err != nil {
return 61, nil, true, err
}
bundle = append(bundle, b...)
bundle = append(bundle, special...)
if s.hook != nil {
if err = s.hook(operation, d, l.Count); err != nil {
return 61, nil, true, err
}
}
old := v.Counts[skuKey(l.Key)]
if old.Lifetime > math.MaxUint64-l.Count {
return 61, nil, true, fmt.Errorf("commerce: lifetime purchase count overflow")
}
v.Counts[skuKey(l.Key)] = purchaseCount{Count: s.count(v, d) + l.Count, Period: s.period(d.PurchaseLimitType), Lifetime: old.Lifetime + l.Count}
}
response := wire.AppendBytes(nil, 1, bundle)
v.Receipts[identity] = purchaseReceipt{digest, response}
for _, b := range billing {
v.Billing[b] = identity
}
encoded, e := json.Marshal(v)
if e == nil {
e = s.store.Save("commerce", encoded)
}
return 61, response, true, e
}
// The receipt repeats the client-visible quote for stale-catalog detection.
// Prices always come from Catalog, and a mismatch never changes the debit.
func validateAcceptedQuote(request []byte, quotes []Product) error {
var pay, receipt string
seen := false
err := wire.Walk(request, func(f wire.Field) error {
if f.Number != 7 {
return nil
}
if seen || f.Type != 2 {
return fmt.Errorf("commerce: invalid billing info")
}
seen = true
return wire.Walk(f.Value, func(b wire.Field) error {
if b.Number == 1 {
if b.Type != 2 || pay != "" {
return fmt.Errorf("commerce: invalid pay id")
}
pay = string(b.Value)
}
if b.Number == 2 {
if b.Type != 2 || receipt != "" {
return fmt.Errorf("commerce: invalid receipt")
}
receipt = string(b.Value)
}
return nil
})
})
if err != nil {
return err
}
parts := strings.Split(receipt, ":")
if len(parts) != 4 || parts[0] != "bd2-local-commerce-v1" || parts[1] != pay {
return fmt.Errorf("commerce: local billing quote required")
}
payID, err := strconv.ParseUint(pay, 10, 64)
if err != nil || payID == 0 || strconv.FormatUint(payID, 10) != pay {
return fmt.Errorf("commerce: invalid local pay id")
}
typ, err := strconv.ParseUint(parts[2], 10, 64)
if err != nil || strconv.FormatUint(typ, 10) != parts[2] {
return fmt.Errorf("commerce: invalid accepted quote currency")
}
amount, err := strconv.ParseUint(parts[3], 10, 64)
if err != nil || strconv.FormatUint(amount, 10) != parts[3] {
return fmt.Errorf("commerce: invalid accepted quote amount")
}
var expected uint64
for _, q := range quotes {
if q.ItemType != typ || q.Cost > math.MaxInt32-expected {
return fmt.Errorf("commerce: accepted quote currency mismatch")
}
expected += q.Cost
}
if expected != amount {
return fmt.Errorf("commerce: purchase quote changed; refresh the shop")
}
return nil
}
func (s *Service) purchaseCountDBInfos() ([][]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
v, e := s.load()
if e != nil {
return nil, e
}
counts := map[gamedata.CashProductKey]uint64{}
if s.legacy != nil {
for _, raw := range s.legacy.PurchaseCountDBInfos() {
g, _, _ := wire.Varint(raw, 1)
id, _, _ := wire.Varint(raw, 2)
sale, _, _ := wire.Varint(raw, 3)
n, _, _ := wire.Varint(raw, 4)
counts[gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale}] = n
}
}
for _, d := range s.catalog.Designs() {
if n := s.count(v, d); n > counts[d.Key] {
counts[d.Key] = n
}
}
keys := make([]gamedata.CashProductKey, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
a, b := keys[i], keys[j]
if a.GroupID != b.GroupID {
return a.GroupID < b.GroupID
}
if a.ProductID != b.ProductID {
return a.ProductID < b.ProductID
}
return a.SaleGroup < b.SaleGroup
})
var out [][]byte
for _, k := range keys {
b := wire.AppendVarint(nil, 1, k.GroupID)
b = wire.AppendVarint(b, 2, k.ProductID)
b = wire.AppendVarint(b, 3, k.SaleGroup)
b = wire.AppendVarint(b, 4, counts[k])
out = append(out, b)
}
return out, nil
}
func (s *Service) PurchaseCountDBInfos() [][]byte { v, _ := s.purchaseCountDBInfos(); return v }
func (s *Service) HasPurchased(k gamedata.CashProductKey) bool {
s.mu.Lock()
defer s.mu.Unlock()
v, e := s.load()
if e != nil {
return false
}
return v.Counts[skuKey(k)].Count > 0
}
// ConsumeEntitlement is an authorization callback within the caller's account
// transaction. Each successful cash activation consumes one lifetime purchase.
// It must not be called recursively from the purchase hook or delegate.
func (s *Service) ConsumeEntitlement(k gamedata.CashProductKey) bool {
s.mu.Lock()
defer s.mu.Unlock()
v, err := s.load()
if err != nil {
return false
}
n := v.Counts[skuKey(k)].Lifetime
used := map[string]uint64{}
raw, err := s.store.Load("commerce_pass_receipts")
if err != nil {
return false
}
if raw != nil {
if err = json.Unmarshal(raw, &used); err != nil || used == nil {
return false
}
}
key := skuKey(k)
if used[key] >= n {
return false
}
used[key]++
raw, err = json.Marshal(used)
if err != nil {
return false
}
return s.store.Save("commerce_pass_receipts", raw) == nil
}