load_balancer.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. package payment
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "log/slog"
  7. "strings"
  8. "sync/atomic"
  9. "time"
  10. dbent "github.com/Wei-Shaw/sub2api/ent"
  11. "github.com/Wei-Shaw/sub2api/ent/paymentorder"
  12. "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
  13. )
  14. // Strategy represents a load balancing strategy for provider instance selection.
  15. type Strategy string
  16. const (
  17. StrategyRoundRobin Strategy = "round-robin"
  18. StrategyLeastAmount Strategy = "least-amount"
  19. )
  20. // ChannelLimits holds limits for a single payment channel within a provider instance.
  21. type ChannelLimits struct {
  22. DailyLimit float64 `json:"dailyLimit,omitempty"`
  23. SingleMin float64 `json:"singleMin,omitempty"`
  24. SingleMax float64 `json:"singleMax,omitempty"`
  25. }
  26. // InstanceLimits holds per-channel limits for a provider instance (JSON).
  27. type InstanceLimits map[string]ChannelLimits
  28. // LoadBalancer selects a provider instance for a given payment type.
  29. type LoadBalancer interface {
  30. GetInstanceConfig(ctx context.Context, instanceID int64) (map[string]string, error)
  31. SelectInstance(ctx context.Context, providerKey string, paymentType PaymentType, strategy Strategy, orderAmount float64) (*InstanceSelection, error)
  32. }
  33. // DefaultLoadBalancer implements LoadBalancer using database queries.
  34. type DefaultLoadBalancer struct {
  35. db *dbent.Client
  36. encryptionKey []byte
  37. counter atomic.Uint64
  38. }
  39. type contextKey string
  40. const wxpayJSAPIAppIDContextKey contextKey = "payment.wxpay.jsapi_app_id"
  41. // NewDefaultLoadBalancer creates a new load balancer.
  42. func NewDefaultLoadBalancer(db *dbent.Client, encryptionKey []byte) *DefaultLoadBalancer {
  43. return &DefaultLoadBalancer{db: db, encryptionKey: encryptionKey}
  44. }
  45. func WithWxpayJSAPIAppID(ctx context.Context, appID string) context.Context {
  46. appID = strings.TrimSpace(appID)
  47. if appID == "" {
  48. return ctx
  49. }
  50. return context.WithValue(ctx, wxpayJSAPIAppIDContextKey, appID)
  51. }
  52. func wxpayJSAPIAppIDFromContext(ctx context.Context) string {
  53. if ctx == nil {
  54. return ""
  55. }
  56. appID, _ := ctx.Value(wxpayJSAPIAppIDContextKey).(string)
  57. return strings.TrimSpace(appID)
  58. }
  59. // instanceCandidate pairs an instance with its pre-fetched daily usage.
  60. type instanceCandidate struct {
  61. inst *dbent.PaymentProviderInstance
  62. dailyUsed float64 // includes PENDING orders
  63. }
  64. // SelectInstance picks an enabled instance for the given provider key and payment type.
  65. //
  66. // Flow:
  67. // 1. Query all enabled instances for providerKey, filter by supported paymentType
  68. // 2. Batch-query daily usage (PENDING + PAID + COMPLETED + RECHARGING) for all candidates
  69. // 3. Filter out instances where: single-min/max violated OR daily remaining < orderAmount
  70. // 4. Pick from survivors using the configured strategy (round-robin / least-amount)
  71. // 5. If all filtered out, fall back to full list (let the provider itself reject)
  72. func (lb *DefaultLoadBalancer) SelectInstance(
  73. ctx context.Context,
  74. providerKey string,
  75. paymentType PaymentType,
  76. strategy Strategy,
  77. orderAmount float64,
  78. ) (*InstanceSelection, error) {
  79. // Step 1: query enabled instances matching payment type.
  80. instances, err := lb.queryEnabledInstances(ctx, providerKey, paymentType)
  81. if err != nil {
  82. return nil, err
  83. }
  84. // Step 2: batch-fetch daily usage for all candidates.
  85. candidates := lb.attachDailyUsage(ctx, instances)
  86. // Step 3: filter by limits.
  87. available := filterByLimits(candidates, paymentType, orderAmount)
  88. if len(available) == 0 {
  89. slog.Warn("all instances exceeded limits, using full candidate list",
  90. "provider", providerKey, "payment_type", paymentType,
  91. "order_amount", orderAmount, "count", len(candidates))
  92. available = candidates
  93. }
  94. // Step 4: pick by strategy.
  95. selected := lb.pickByStrategy(available, strategy)
  96. return lb.buildSelection(selected.inst)
  97. }
  98. // queryEnabledInstances returns enabled instances that support paymentType.
  99. // When providerKey is non-empty, only instances with that provider key are considered.
  100. // When providerKey is empty, instances across all providers are considered,
  101. // enabling cross-provider load balancing (e.g. EasyPay + Alipay direct for "alipay").
  102. func (lb *DefaultLoadBalancer) queryEnabledInstances(
  103. ctx context.Context,
  104. providerKey string,
  105. paymentType PaymentType,
  106. ) ([]*dbent.PaymentProviderInstance, error) {
  107. query := lb.db.PaymentProviderInstance.Query().
  108. Where(paymentproviderinstance.Enabled(true))
  109. if providerKey != "" {
  110. query = query.Where(paymentproviderinstance.ProviderKey(providerKey))
  111. }
  112. instances, err := query.
  113. Order(dbent.Asc(paymentproviderinstance.FieldSortOrder)).
  114. All(ctx)
  115. if err != nil {
  116. return nil, fmt.Errorf("query provider instances: %w", err)
  117. }
  118. var matched []*dbent.PaymentProviderInstance
  119. expectedWxpayJSAPIAppID := wxpayJSAPIAppIDFromContext(ctx)
  120. for _, inst := range instances {
  121. // Stripe: match by provider_key because supported_types lists sub-types (card,link,alipay,wxpay),
  122. // not "stripe" itself. The checkout page aggregates all sub-types under "stripe".
  123. if paymentType == TypeStripe {
  124. if inst.ProviderKey == TypeStripe {
  125. matched = append(matched, inst)
  126. }
  127. } else if InstanceSupportsType(inst.SupportedTypes, paymentType) {
  128. if expectedWxpayJSAPIAppID != "" && normalizeVisibleMethodSupportType(paymentType) == TypeWxpay && inst.ProviderKey == TypeWxpay {
  129. config, cfgErr := lb.decryptConfig(inst.Config)
  130. if cfgErr != nil {
  131. slog.Warn("skip wxpay instance with unreadable config during jsapi filtering", "instance_id", inst.ID, "error", cfgErr)
  132. continue
  133. }
  134. if resolveWxpayJSAPIAppID(config) != expectedWxpayJSAPIAppID {
  135. continue
  136. }
  137. }
  138. matched = append(matched, inst)
  139. }
  140. }
  141. if len(matched) == 0 {
  142. return nil, fmt.Errorf("no enabled instance for payment type %s", paymentType)
  143. }
  144. return matched, nil
  145. }
  146. // attachDailyUsage queries daily usage for each instance in a single pass.
  147. // Usage includes PENDING orders to avoid over-committing capacity.
  148. func (lb *DefaultLoadBalancer) attachDailyUsage(
  149. ctx context.Context,
  150. instances []*dbent.PaymentProviderInstance,
  151. ) []instanceCandidate {
  152. todayStart := startOfDay(time.Now())
  153. // Collect instance IDs.
  154. ids := make([]string, len(instances))
  155. for i, inst := range instances {
  156. ids[i] = fmt.Sprintf("%d", inst.ID)
  157. }
  158. // Batch query: sum pay_amount grouped by provider_instance_id.
  159. type row struct {
  160. InstanceID string `json:"provider_instance_id"`
  161. Sum float64 `json:"sum"`
  162. }
  163. var rows []row
  164. err := lb.db.PaymentOrder.Query().
  165. Where(
  166. paymentorder.ProviderInstanceIDIn(ids...),
  167. paymentorder.StatusIn(
  168. OrderStatusPending, OrderStatusPaid,
  169. OrderStatusCompleted, OrderStatusRecharging,
  170. ),
  171. paymentorder.CreatedAtGTE(todayStart),
  172. ).
  173. GroupBy(paymentorder.FieldProviderInstanceID).
  174. Aggregate(dbent.Sum(paymentorder.FieldPayAmount)).
  175. Scan(ctx, &rows)
  176. if err != nil {
  177. slog.Warn("batch daily usage query failed, treating all as zero", "error", err)
  178. }
  179. usageMap := make(map[string]float64, len(rows))
  180. for _, r := range rows {
  181. usageMap[r.InstanceID] = r.Sum
  182. }
  183. candidates := make([]instanceCandidate, len(instances))
  184. for i, inst := range instances {
  185. candidates[i] = instanceCandidate{
  186. inst: inst,
  187. dailyUsed: usageMap[fmt.Sprintf("%d", inst.ID)],
  188. }
  189. }
  190. return candidates
  191. }
  192. // filterByLimits removes instances that cannot accommodate the order:
  193. // - orderAmount outside single-transaction [min, max]
  194. // - daily remaining capacity (limit - used) < orderAmount
  195. func filterByLimits(candidates []instanceCandidate, paymentType PaymentType, orderAmount float64) []instanceCandidate {
  196. var result []instanceCandidate
  197. for _, c := range candidates {
  198. cl := getInstanceChannelLimits(c.inst, paymentType)
  199. if cl.SingleMin > 0 && orderAmount < cl.SingleMin {
  200. slog.Info("order below instance single min, skipping",
  201. "instance_id", c.inst.ID, "order", orderAmount, "min", cl.SingleMin)
  202. continue
  203. }
  204. if cl.SingleMax > 0 && orderAmount > cl.SingleMax {
  205. slog.Info("order above instance single max, skipping",
  206. "instance_id", c.inst.ID, "order", orderAmount, "max", cl.SingleMax)
  207. continue
  208. }
  209. if cl.DailyLimit > 0 && c.dailyUsed+orderAmount > cl.DailyLimit {
  210. slog.Info("instance daily remaining insufficient, skipping",
  211. "instance_id", c.inst.ID, "used", c.dailyUsed,
  212. "order", orderAmount, "limit", cl.DailyLimit)
  213. continue
  214. }
  215. result = append(result, c)
  216. }
  217. return result
  218. }
  219. // getInstanceChannelLimits returns the channel limits for a specific payment type.
  220. func getInstanceChannelLimits(inst *dbent.PaymentProviderInstance, paymentType PaymentType) ChannelLimits {
  221. if inst.Limits == "" {
  222. return ChannelLimits{}
  223. }
  224. var limits InstanceLimits
  225. if err := json.Unmarshal([]byte(inst.Limits), &limits); err != nil {
  226. return ChannelLimits{}
  227. }
  228. // For Stripe, limits are stored under the provider key "stripe".
  229. lookupKey := paymentType
  230. if inst.ProviderKey == "stripe" {
  231. lookupKey = "stripe"
  232. }
  233. if cl, ok := limits[lookupKey]; ok {
  234. return cl
  235. }
  236. if aliasKey := legacyVisibleMethodAlias(lookupKey); aliasKey != "" {
  237. if cl, ok := limits[aliasKey]; ok {
  238. return cl
  239. }
  240. }
  241. return ChannelLimits{}
  242. }
  243. // pickByStrategy selects one instance from the available candidates.
  244. func (lb *DefaultLoadBalancer) pickByStrategy(candidates []instanceCandidate, strategy Strategy) instanceCandidate {
  245. if strategy == StrategyLeastAmount && len(candidates) > 1 {
  246. return pickLeastAmount(candidates)
  247. }
  248. // Default: round-robin.
  249. idx := lb.counter.Add(1) % uint64(len(candidates))
  250. return candidates[idx]
  251. }
  252. // pickLeastAmount selects the instance with the lowest daily usage.
  253. // No extra DB queries — usage was pre-fetched in attachDailyUsage.
  254. func pickLeastAmount(candidates []instanceCandidate) instanceCandidate {
  255. best := candidates[0]
  256. for _, c := range candidates[1:] {
  257. if c.dailyUsed < best.dailyUsed {
  258. best = c
  259. }
  260. }
  261. return best
  262. }
  263. func (lb *DefaultLoadBalancer) buildSelection(selected *dbent.PaymentProviderInstance) (*InstanceSelection, error) {
  264. config, err := lb.decryptConfig(selected.Config)
  265. if err != nil {
  266. return nil, fmt.Errorf("decrypt instance %d config: %w", selected.ID, err)
  267. }
  268. if config == nil {
  269. config = map[string]string{}
  270. }
  271. if selected.PaymentMode != "" {
  272. config["paymentMode"] = selected.PaymentMode
  273. }
  274. return &InstanceSelection{
  275. InstanceID: fmt.Sprintf("%d", selected.ID),
  276. ProviderKey: selected.ProviderKey,
  277. Config: config,
  278. SupportedTypes: selected.SupportedTypes,
  279. PaymentMode: selected.PaymentMode,
  280. }, nil
  281. }
  282. // decryptConfig parses a stored provider config.
  283. // New records are plaintext JSON; legacy records are AES-256-GCM ciphertext.
  284. // Unreadable values (legacy ciphertext without a valid key, or malformed data)
  285. // are treated as empty so the service keeps running while the admin re-enters
  286. // the config via the UI.
  287. //
  288. // TODO(deprecated-legacy-ciphertext): The AES fallback branch below is a
  289. // transitional compatibility shim for pre-plaintext records. Remove it (and
  290. // the encryptionKey field + the Decrypt import) after a few releases once all
  291. // live deployments have re-saved their provider configs through the UI.
  292. func (lb *DefaultLoadBalancer) decryptConfig(stored string) (map[string]string, error) {
  293. if stored == "" {
  294. return nil, nil
  295. }
  296. var config map[string]string
  297. if err := json.Unmarshal([]byte(stored), &config); err == nil {
  298. return config, nil
  299. }
  300. // Deprecated: legacy AES-256-GCM ciphertext fallback — scheduled for removal.
  301. if len(lb.encryptionKey) == AES256KeySize {
  302. //nolint:staticcheck // SA1019: intentional legacy fallback, scheduled for removal
  303. if plaintext, err := Decrypt(stored, lb.encryptionKey); err == nil {
  304. if err := json.Unmarshal([]byte(plaintext), &config); err == nil {
  305. return config, nil
  306. }
  307. }
  308. }
  309. slog.Warn("payment provider config unreadable, treating as empty for re-entry",
  310. "stored_len", len(stored))
  311. return nil, nil
  312. }
  313. // GetInstanceDailyAmount returns the total completed order amount for an instance today.
  314. func (lb *DefaultLoadBalancer) GetInstanceDailyAmount(ctx context.Context, instanceID string) (float64, error) {
  315. todayStart := startOfDay(time.Now())
  316. var result []struct {
  317. Sum float64 `json:"sum"`
  318. }
  319. err := lb.db.PaymentOrder.Query().
  320. Where(
  321. paymentorder.ProviderInstanceID(instanceID),
  322. paymentorder.StatusIn(OrderStatusCompleted, OrderStatusPaid, OrderStatusRecharging),
  323. paymentorder.PaidAtGTE(todayStart),
  324. ).
  325. Aggregate(dbent.Sum(paymentorder.FieldPayAmount)).
  326. Scan(ctx, &result)
  327. if err != nil {
  328. return 0, fmt.Errorf("query daily amount: %w", err)
  329. }
  330. if len(result) > 0 {
  331. return result[0].Sum, nil
  332. }
  333. return 0, nil
  334. }
  335. func startOfDay(t time.Time) time.Time {
  336. return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
  337. }
  338. // InstanceSupportsType checks if the given supported types string includes the target type.
  339. // An empty supportedTypes string means all types are supported.
  340. func InstanceSupportsType(supportedTypes string, target PaymentType) bool {
  341. if supportedTypes == "" {
  342. return true
  343. }
  344. normalizedTarget := normalizeVisibleMethodSupportType(target)
  345. for _, t := range strings.Split(supportedTypes, ",") {
  346. supported := strings.TrimSpace(t)
  347. if supported == target || normalizeVisibleMethodSupportType(supported) == normalizedTarget {
  348. return true
  349. }
  350. }
  351. return false
  352. }
  353. func normalizeVisibleMethodSupportType(paymentType PaymentType) PaymentType {
  354. switch strings.TrimSpace(paymentType) {
  355. case TypeAlipay, TypeAlipayDirect:
  356. return TypeAlipay
  357. case TypeWxpay, TypeWxpayDirect:
  358. return TypeWxpay
  359. default:
  360. return strings.TrimSpace(paymentType)
  361. }
  362. }
  363. func legacyVisibleMethodAlias(paymentType PaymentType) PaymentType {
  364. switch normalizeVisibleMethodSupportType(paymentType) {
  365. case TypeAlipay:
  366. return TypeAlipayDirect
  367. case TypeWxpay:
  368. return TypeWxpayDirect
  369. default:
  370. return ""
  371. }
  372. }
  373. func resolveWxpayJSAPIAppID(config map[string]string) string {
  374. if appID := strings.TrimSpace(config["mpAppId"]); appID != "" {
  375. return appID
  376. }
  377. return strings.TrimSpace(config["appId"])
  378. }
  379. // GetInstanceConfig decrypts and returns the configuration for a provider instance by ID.
  380. func (lb *DefaultLoadBalancer) GetInstanceConfig(ctx context.Context, instanceID int64) (map[string]string, error) {
  381. inst, err := lb.db.PaymentProviderInstance.Get(ctx, instanceID)
  382. if err != nil {
  383. return nil, fmt.Errorf("get instance %d: %w", instanceID, err)
  384. }
  385. return lb.decryptConfig(inst.Config)
  386. }