package finance import ( "context" "crypto/aes" "crypto/cipher" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "regexp" "strconv" "strings" "time" "unicode" "unicode/utf8" "git.118im.com/im_team/olons_server/internal/config" "git.118im.com/im_team/olons_server/internal/store" "github.com/redis/go-redis/v9" "golang.org/x/crypto/bcrypt" ) const ( defaultPageSize = 20 maxPageSize = 100 quoteTTL = 5 * time.Minute passwordMaxTry = 5 ) var ( tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`) idempotencyPattern = regexp.MustCompile(`^[A-Za-z0-9._:\-]{8,80}$`) ) type Repository interface { CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error) PaymentAccountDetails(context.Context, int64, int64) ([]store.PaymentAccount, error) CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error) UpdatePaymentAccount(context.Context, int64, store.PaymentAccountCreation) (*store.PaymentAccount, error) PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error) ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error) WithdrawalWallet(context.Context, int64, int64) (*store.WithdrawalWallet, error) CreateWithdrawal(context.Context, store.WithdrawalCreation) (*store.WithdrawalOrder, error) WithdrawalOrders(context.Context, int64, int64, int, int, int) (*store.WithdrawalPage, error) WithdrawalOrder(context.Context, int64, int64, int64) (*store.WithdrawalOrder, error) RechargeChannels(context.Context, int64) ([]store.RechargeChannel, error) CreateRecharge(context.Context, store.RechargeCreation) (*store.RechargeOrder, error) RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, error) RechargeOrder(context.Context, int64, int64, int64) (*store.RechargeOrder, error) WalletLedgers(context.Context, int64, int64, string, int, int, int) (*store.WalletLedgerPage, error) } type Service struct { repository Repository redis *redis.Client aead cipher.AEAD legacyAEADs []cipher.AEAD lookupKey []byte ready bool now func() time.Time } type PaymentAccountInput struct { ID int64 `json:"id"` BankName string `json:"bank_name"` HolderName string `json:"holder_name"` AccountNo string `json:"account_no"` DigitalRMBCode string `json:"digital_rmb_code"` WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"` AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"` } type PaymentAccount struct { ID int64 `json:"id"` AccountType string `json:"account_type"` BankCode string `json:"bank_code"` BankName string `json:"bank_name"` HolderMask string `json:"holder_mask"` AccountNoMask string `json:"account_no_mask"` DigitalRMBCodeMask string `json:"digital_rmb_code_mask"` WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"` AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"` VerifyStatus string `json:"verify_status"` IsDefault bool `json:"is_default"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` } type PaymentAccountDetail struct { ID int64 `json:"id"` BankName string `json:"bank_name"` HolderName string `json:"holder_name"` AccountNo string `json:"account_no"` DigitalRMBCode string `json:"digital_rmb_code"` WechatQRURL string `json:"wechat_qr_url"` AlipayQRURL string `json:"alipay_qr_url"` WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"` AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"` VerifyStatus string `json:"verify_status"` IsDefault bool `json:"is_default"` Masked bool `json:"masked"` } type QuoteInput struct { PaymentAccountID int64 `json:"payment_account_id"` Amount int64 `json:"amount"` Currency string `json:"currency"` } type Quote struct { PaymentAccountID int64 `json:"payment_account_id"` AccountMask string `json:"account_mask"` ChannelID int64 `json:"channel_id"` ChannelCode string `json:"channel_code"` ChannelName string `json:"channel_name"` RequestAmount int64 `json:"request_amount"` FeeAmount int64 `json:"fee_amount"` ActualAmount int64 `json:"actual_amount"` Currency string `json:"currency"` MinimumAmount int64 `json:"minimum_amount"` MaximumAmount int64 `json:"maximum_amount"` AvailableAmount int64 `json:"available_amount"` TodayCount int `json:"today_count"` DailyLimit int `json:"daily_limit"` QuoteExpiresAt string `json:"quote_expires_at"` } type CreateWithdrawalInput struct { PaymentAccountID int64 `json:"payment_account_id"` Amount int64 `json:"amount"` Currency string `json:"currency"` TradePassword string `json:"trade_password"` } type Withdrawal struct { ID int64 `json:"id"` WithdrawalNo string `json:"withdrawal_no"` PaymentAccountID int64 `json:"payment_account_id"` ChannelID int64 `json:"channel_id"` ChannelName string `json:"channel_name"` RequestAmount int64 `json:"request_amount"` FeeAmount int64 `json:"fee_amount"` ActualAmount int64 `json:"actual_amount"` Currency string `json:"currency"` AccountMask string `json:"account_mask"` BankName string `json:"bank_name"` HolderMask string `json:"holder_mask"` Status string `json:"status"` StatusText string `json:"status_text"` ReasonCode string `json:"reason_code"` ReasonText string `json:"reason_text"` ExternalNo string `json:"external_no,omitempty"` RiskResult json.RawMessage `json:"risk_result,omitempty"` ReviewedAt string `json:"reviewed_at,omitempty"` ProcessedAt string `json:"processed_at,omitempty"` CompletedAt string `json:"completed_at,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` IdempotentReplay bool `json:"idempotent_replay,omitempty"` } type WithdrawalPage struct { Items []Withdrawal `json:"items"` Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` } type RechargeChannel struct { ID int64 `json:"id"` Code string `json:"channel_code"` Name string `json:"name"` ChannelType int `json:"channel_type"` MinimumAmount int64 `json:"minimum_amount"` MaximumAmount int64 `json:"maximum_amount"` FeeType int `json:"fee_type"` FeeFixedAmount int64 `json:"fee_fixed_amount"` FeeRate int `json:"fee_rate"` } type CreateRechargeInput struct { ChannelID int64 `json:"channel_id"` Amount int64 `json:"amount"` Currency string `json:"currency"` VoucherAttachmentIDs []int64 `json:"voucher_attachment_ids"` Remark string `json:"remark"` } type Recharge struct { ID int64 `json:"id"` RechargeNo string `json:"recharge_no"` ChannelID int64 `json:"channel_id"` ChannelName string `json:"channel_name"` RequestAmount int64 `json:"request_amount"` PayableAmount int64 `json:"payable_amount"` PaidAmount int64 `json:"paid_amount"` Currency string `json:"currency"` VoucherAttachmentIDs json.RawMessage `json:"voucher_attachment_ids"` Status string `json:"status"` StatusText string `json:"status_text"` ReasonCode string `json:"reason_code"` ReasonText string `json:"reason_text"` Remark string `json:"remark"` PaymentInstructions string `json:"payment_instructions"` ExpireAt string `json:"expire_at,omitempty"` PaidAt string `json:"paid_at,omitempty"` ReviewedAt string `json:"reviewed_at,omitempty"` CreditedAt string `json:"credited_at,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type RechargePage struct { Items []Recharge `json:"items"` Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` HasNext bool `json:"has_next"` } type WalletLedger struct { ID int64 `json:"id"` LedgerNo string `json:"ledger_no"` BizType string `json:"biz_type"` BizNo string `json:"biz_no"` EntryType string `json:"entry_type"` Direction string `json:"direction"` Amount int64 `json:"amount"` BeforeAmount int64 `json:"before_amount"` AfterAmount int64 `json:"after_amount"` Currency string `json:"currency"` Remark string `json:"remark"` OccurredAt string `json:"occurred_at"` } type WalletLedgerPage struct { Items []WalletLedger `json:"items"` Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` HasNext bool `json:"has_next"` } func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service { service := &Service{repository: repository, redis: redisClient, now: time.Now} masterKey := strings.TrimSpace(cfg.FinanceMasterKey) var encryptionKey, lookupKey []byte if len([]byte(masterKey)) >= 32 { encryptionKey = deriveKey(masterKey, "data-encryption") lookupKey = deriveKey(masterKey, "lookup-hmac") } else { var encryptionErr, lookupErr error encryptionKey, encryptionErr = decodeKey(cfg.DataEncryptionKey) lookupKey, lookupErr = decodeKey(cfg.LookupHMACKey) if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 { return service } } aead, err := newAEAD(encryptionKey) if err != nil { return service } service.aead = aead seenLegacyKeys := make(map[string]struct{}, len(cfg.FinanceLegacyMasterKeys)) for _, legacyMasterKey := range cfg.FinanceLegacyMasterKeys { legacyMasterKey = strings.TrimSpace(legacyMasterKey) if len([]byte(legacyMasterKey)) < 32 || legacyMasterKey == masterKey { continue } if _, exists := seenLegacyKeys[legacyMasterKey]; exists { continue } legacyAEAD, legacyErr := newAEAD(deriveKey(legacyMasterKey, "data-encryption")) if legacyErr == nil { service.legacyAEADs = append(service.legacyAEADs, legacyAEAD) seenLegacyKeys[legacyMasterKey] = struct{}{} } } service.lookupKey = lookupKey service.ready = repository != nil && redisClient != nil return service } func newAEAD(key []byte) (cipher.AEAD, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } return cipher.NewGCM(block) } func deriveKey(masterKey, purpose string) []byte { mac := hmac.New(sha256.New, []byte(masterKey)) _, _ = mac.Write([]byte("olons-finance:" + purpose + ":v1")) return mac.Sum(nil) } func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) { if s.repository == nil { return nil, ErrUnavailable } rows, err := s.repository.PaymentAccounts(ctx, tenantID, memberID) if err != nil { return nil, err } items := make([]PaymentAccount, 0, len(rows)) for _, row := range rows { items = append(items, paymentAccountFromStore(row)) } return items, nil } func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID int64) ([]PaymentAccountDetail, error) { if s.repository == nil { return nil, ErrUnavailable } rows, err := s.repository.PaymentAccountDetails(ctx, tenantID, memberID) if err != nil { return nil, err } items := make([]PaymentAccountDetail, 0, len(rows)) for _, row := range rows { holderName, accountNo := row.HolderMask, row.AccountNoMask digitalRMBCode := row.DigitalRMBCodeMask masked := true if s.ready { decryptedHolder, holderErr := s.decrypt(row.HolderCipher) decryptedAccount, accountErr := s.decrypt(row.AccountNoCipher) if holderErr == nil && accountErr == nil { holderName, accountNo, masked = decryptedHolder, decryptedAccount, false if row.DigitalRMBCodeCipher != "" { if value, decryptErr := s.decrypt(row.DigitalRMBCodeCipher); decryptErr == nil { digitalRMBCode = value } else { masked = true } } } } items = append(items, PaymentAccountDetail{ ID: row.ID, BankName: row.BankName, HolderName: holderName, AccountNo: accountNo, DigitalRMBCode: digitalRMBCode, WechatQRURL: row.WechatQRURL, AlipayQRURL: row.AlipayQRURL, WechatQRAttachmentID: row.WechatQRAttachmentID, AlipayQRAttachmentID: row.AlipayQRAttachmentID, VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1, Masked: masked, }) } return items, nil } func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) { data, err := s.paymentAccountCreation(tenantID, memberID, input) if err != nil { return nil, err } row, err := s.repository.CreatePaymentAccount(ctx, data) if err != nil { return nil, err } result := paymentAccountFromStore(*row) return &result, nil } func (s *Service) UpdatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) { if input.ID <= 0 { return nil, ErrAccountFieldsInvalid } data, err := s.paymentAccountCreation(tenantID, memberID, input) if err != nil { return nil, err } row, err := s.repository.UpdatePaymentAccount(ctx, input.ID, data) if err != nil { return nil, err } result := paymentAccountFromStore(*row) return &result, nil } func (s *Service) paymentAccountCreation(tenantID, memberID int64, input PaymentAccountInput) (store.PaymentAccountCreation, error) { if !s.ready { return store.PaymentAccountCreation{}, ErrUnavailable } if err := normalizePaymentAccount(&input); err != nil { return store.PaymentAccountCreation{}, err } holderCipher, err := s.encrypt(input.HolderName) if err != nil { return store.PaymentAccountCreation{}, err } accountCipher, err := s.encrypt(input.AccountNo) if err != nil { return store.PaymentAccountCreation{}, err } digitalRMBCodeCipher, digitalRMBCodeHash, digitalRMBCodeMask := "", "", "" if input.DigitalRMBCode != "" { digitalRMBCodeCipher, err = s.encrypt(input.DigitalRMBCode) if err != nil { return store.PaymentAccountCreation{}, err } digitalRMBCodeHash = s.lookupHash(input.DigitalRMBCode) digitalRMBCodeMask = maskAccount(input.DigitalRMBCode) } return store.PaymentAccountCreation{ TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName, HolderCipher: holderCipher, HolderMask: maskName(input.HolderName), AccountNoCipher: accountCipher, AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo), DigitalRMBCodeCipher: digitalRMBCodeCipher, DigitalRMBCodeHash: digitalRMBCodeHash, DigitalRMBCodeMask: digitalRMBCodeMask, WechatQRAttachmentID: input.WechatQRAttachmentID, AlipayQRAttachmentID: input.AlipayQRAttachmentID, }, nil } func (s *Service) Quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) { if !s.ready { return nil, ErrUnavailable } if err := s.ensureWithdrawalEnabled(ctx, tenantID); err != nil { return nil, err } return s.quote(ctx, tenantID, memberID, input) } func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64, idempotencyKey string, input CreateWithdrawalInput) (*Withdrawal, error) { if !s.ready { return nil, ErrUnavailable } idempotencyKey = strings.TrimSpace(idempotencyKey) if !idempotencyPattern.MatchString(idempotencyKey) { return nil, ErrIdempotencyKey } if err := s.ensureWithdrawalEnabled(ctx, tenantID); err != nil { return nil, err } security, err := s.verifyTradePassword(ctx, tenantID, memberID, input.TradePassword) if err != nil { return nil, err } quote, account, channel, err := s.quoteData(ctx, tenantID, memberID, QuoteInput{ PaymentAccountID: input.PaymentAccountID, Amount: input.Amount, Currency: input.Currency}) if err != nil { return nil, err } holderName, err := s.decrypt(account.HolderCipher) if err != nil { return nil, store.ErrPaymentAccountInvalid } accountNo, err := s.decrypt(account.AccountNoCipher) if err != nil { return nil, store.ErrPaymentAccountInvalid } digitalRMBCode := "" if account.DigitalRMBCodeCipher != "" { digitalRMBCode, err = s.decrypt(account.DigitalRMBCodeCipher) if err != nil { return nil, store.ErrPaymentAccountInvalid } } snapshot, err := json.Marshal(map[string]any{ "account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode, "bank_name": account.BankName, "holder_name": holderName, "account_no": accountNo, "digital_rmb_code": digitalRMBCode, "wechat_qr_attachment_id": account.WechatQRAttachmentID, "alipay_qr_attachment_id": account.AlipayQRAttachmentID, }) if err != nil { return nil, err } snapshotCipher, err := s.encrypt(string(snapshot)) if err != nil { return nil, err } withdrawalNo := deterministicNumber("WD", tenantID, memberID, idempotencyKey) row, err := s.repository.CreateWithdrawal(ctx, store.WithdrawalCreation{ TenantID: tenantID, MemberID: memberID, ExpectedTradePasswordHash: security.TradePasswordHash, WithdrawalNo: withdrawalNo, PaymentAccountID: account.ID, ExpectedAccountType: account.AccountType, ExpectedBankCode: account.BankCode, ExpectedBankName: account.BankName, ExpectedHolderCipher: account.HolderCipher, ExpectedIdentityNoCipher: account.IdentityNoCipher, ExpectedIdentityNoHash: account.IdentityNoHash, ExpectedAccountNoCipher: account.AccountNoCipher, ExpectedAccountNoHash: account.AccountNoHash, ExpectedDigitalRMBCodeCipher: account.DigitalRMBCodeCipher, ExpectedDigitalRMBCodeHash: account.DigitalRMBCodeHash, ExpectedWechatQRAttachmentID: account.WechatQRAttachmentID, ExpectedAlipayQRAttachmentID: account.AlipayQRAttachmentID, Channel: *channel, RequestAmount: quote.RequestAmount, FeeAmount: quote.FeeAmount, ActualAmount: quote.ActualAmount, Currency: quote.Currency, AccountSnapshotCipher: snapshotCipher, AccountMask: account.AccountNoMask, BankName: account.BankName, HolderMask: account.HolderMask, AvailableLedgerNo: deterministicNumber("LA", tenantID, memberID, idempotencyKey), FrozenLedgerNo: deterministicNumber("LF", tenantID, memberID, idempotencyKey), }) if err != nil { return nil, err } result := withdrawalFromStore(*row) return &result, nil } func (s *Service) WithdrawalOrders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*WithdrawalPage, error) { if s.repository == nil { return nil, ErrUnavailable } if page == 0 { page = 1 } if pageSize == 0 { pageSize = defaultPageSize } if page < 1 { return nil, ErrPageInvalid } if pageSize < 1 || pageSize > maxPageSize { return nil, ErrPageSizeInvalid } statusCode, ok := withdrawalStatusCode(status) if !ok { return nil, ErrStatusInvalid } rows, err := s.repository.WithdrawalOrders(ctx, tenantID, memberID, statusCode, (page-1)*pageSize, pageSize) if err != nil { return nil, err } items := make([]Withdrawal, 0, len(rows.Items)) for _, row := range rows.Items { items = append(items, withdrawalFromStore(row)) } return &WithdrawalPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil } func (s *Service) WithdrawalOrder(ctx context.Context, tenantID, memberID, orderID int64) (*Withdrawal, error) { if s.repository == nil { return nil, ErrUnavailable } if orderID <= 0 { return nil, ErrOrderIDInvalid } row, err := s.repository.WithdrawalOrder(ctx, tenantID, memberID, orderID) if err != nil { return nil, err } result := withdrawalFromStore(*row) return &result, nil } func (s *Service) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) { if s.repository == nil { return nil, ErrUnavailable } rows, err := s.repository.RechargeChannels(ctx, tenantID) if err != nil { return nil, err } items := make([]RechargeChannel, 0, len(rows)) for _, row := range rows { items = append(items, RechargeChannel{ID: row.ID, Code: row.Code, Name: row.Name, ChannelType: row.ChannelType, MinimumAmount: greaterInt64(1, row.MinAmount), MaximumAmount: row.MaxAmount, FeeType: row.FeeType, FeeFixedAmount: row.FeeFixedAmount, FeeRate: row.FeeRate}) } return items, nil } func (s *Service) CreateRecharge(ctx context.Context, tenantID, memberID int64, input CreateRechargeInput) (*Recharge, error) { if s.repository == nil { return nil, ErrUnavailable } input.Currency = strings.ToUpper(strings.TrimSpace(input.Currency)) input.Remark = strings.TrimSpace(input.Remark) if input.Currency != "CNY" { return nil, ErrCurrencyInvalid } if input.ChannelID <= 0 { return nil, store.ErrRechargeChannel } if input.Amount <= 0 { return nil, store.ErrRechargeAmount } if utf8.RuneCountInString(input.Remark) > 500 { return nil, ErrRemarkInvalid } if len(input.VoucherAttachmentIDs) > 9 { return nil, ErrVoucherInvalid } seen := make(map[int64]struct{}, len(input.VoucherAttachmentIDs)) for _, id := range input.VoucherAttachmentIDs { if id <= 0 { return nil, ErrVoucherInvalid } if _, exists := seen[id]; exists { return nil, ErrVoucherInvalid } seen[id] = struct{}{} } channels, err := s.repository.RechargeChannels(ctx, tenantID) if err != nil { return nil, err } var channel *store.RechargeChannel for i := range channels { if channels[i].ID == input.ChannelID { channel = &channels[i] break } } if channel == nil { return nil, store.ErrRechargeChannel } if input.Amount < greaterInt64(1, channel.MinAmount) || (channel.MaxAmount > 0 && input.Amount > channel.MaxAmount) { return nil, store.ErrRechargeAmount } orderNo, err := randomNumber("RC") if err != nil { return nil, err } row, err := s.repository.CreateRecharge(ctx, store.RechargeCreation{TenantID: tenantID, MemberID: memberID, RechargeNo: orderNo, ChannelID: input.ChannelID, RequestAmount: input.Amount, Currency: input.Currency, VoucherAttachmentIDs: input.VoucherAttachmentIDs, Remark: input.Remark}) if err != nil { return nil, err } result := rechargeFromStore(*row) return &result, nil } func (s *Service) RechargeOrders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*RechargePage, error) { if s.repository == nil { return nil, ErrUnavailable } if page == 0 { page = 1 } if pageSize == 0 { pageSize = defaultPageSize } if page < 1 { return nil, ErrPageInvalid } if pageSize < 1 || pageSize > maxPageSize { return nil, ErrPageSizeInvalid } statusCode, ok := rechargeStatusCode(status) if !ok { return nil, ErrRechargeStatusInvalid } rows, err := s.repository.RechargeOrders(ctx, tenantID, memberID, statusCode, (page-1)*pageSize, pageSize) if err != nil { return nil, err } items := make([]Recharge, 0, len(rows.Items)) for _, row := range rows.Items { items = append(items, rechargeFromStore(row)) } return &RechargePage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total, HasNext: int64(page*pageSize) < rows.Total}, nil } func (s *Service) RechargeOrder(ctx context.Context, tenantID, memberID, orderID int64) (*Recharge, error) { if s.repository == nil { return nil, ErrUnavailable } if orderID <= 0 { return nil, ErrRechargeOrderIDInvalid } row, err := s.repository.RechargeOrder(ctx, tenantID, memberID, orderID) if err != nil { return nil, err } result := rechargeFromStore(*row) return &result, nil } func (s *Service) WalletLedgers(ctx context.Context, tenantID, memberID int64, page, pageSize int, bizType, direction string) (*WalletLedgerPage, error) { if s.repository == nil { return nil, ErrUnavailable } if page == 0 { page = 1 } if pageSize == 0 { pageSize = defaultPageSize } if page < 1 { return nil, ErrPageInvalid } if pageSize < 1 || pageSize > maxPageSize { return nil, ErrPageSizeInvalid } bizType = strings.TrimSpace(bizType) if len(bizType) > 32 { return nil, ErrLedgerFilterInvalid } directionCode := 0 switch strings.ToLower(strings.TrimSpace(direction)) { case "", "all": case "income": directionCode = 1 case "expense": directionCode = 2 default: return nil, ErrLedgerFilterInvalid } rows, err := s.repository.WalletLedgers(ctx, tenantID, memberID, bizType, directionCode, (page-1)*pageSize, pageSize) if err != nil { return nil, err } items := make([]WalletLedger, 0, len(rows.Items)) for _, row := range rows.Items { directionName := "expense" if row.Direction == 1 { directionName = "income" } items = append(items, WalletLedger{ ID: row.ID, LedgerNo: row.LedgerNo, BizType: row.BizType, BizNo: row.BizNo, EntryType: row.EntryType, Direction: directionName, Amount: row.Amount, BeforeAmount: row.BeforeAmount, AfterAmount: row.AfterAmount, Currency: row.Currency, Remark: row.Remark, OccurredAt: row.OccurredAt, }) } return &WalletLedgerPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total, HasNext: int64(page*pageSize) < rows.Total}, nil } func (s *Service) quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) { quote, _, _, err := s.quoteData(ctx, tenantID, memberID, input) return quote, err } func (s *Service) quoteData(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, *store.PaymentAccount, *store.WithdrawalChannel, error) { input.Currency = strings.ToUpper(strings.TrimSpace(input.Currency)) if input.PaymentAccountID <= 0 { return nil, nil, nil, store.ErrPaymentAccountInvalid } if input.Currency != "CNY" { return nil, nil, nil, ErrCurrencyInvalid } if input.Amount <= 0 { return nil, nil, nil, ErrAmountInvalid } account, err := s.repository.PaymentAccountForWithdrawal(ctx, tenantID, memberID, input.PaymentAccountID) if err != nil { return nil, nil, nil, err } if account.VerifyStatus != 2 { return nil, nil, nil, store.ErrPaymentAccountInvalid } channel, err := s.repository.ActiveWithdrawalChannel(ctx, tenantID, channelTypes(account.AccountType)) if err != nil { return nil, nil, nil, err } wallet, err := s.repository.WithdrawalWallet(ctx, tenantID, memberID) if err != nil { return nil, nil, nil, err } if wallet.AccountStatus != 1 { return nil, nil, nil, store.ErrAccountRestricted } minimum := channel.MinAmount if minimum < 1 { minimum = 1 } maximum := channel.MaxAmount if maximum == 0 || maximum > wallet.AvailableAmount { maximum = wallet.AvailableAmount } if input.Amount < minimum || (channel.MaxAmount > 0 && input.Amount > channel.MaxAmount) { return nil, nil, nil, store.ErrWithdrawalAmount } if wallet.DailyLimit > 0 && wallet.TodayCount >= wallet.DailyLimit { return nil, nil, nil, store.ErrWithdrawalLimit } if input.Amount > wallet.AvailableAmount { return nil, nil, nil, store.ErrInsufficientBalance } fee, err := withdrawalFee(input.Amount, *channel) if err != nil || fee < 0 || fee >= input.Amount { return nil, nil, nil, store.ErrWithdrawalAmount } return &Quote{PaymentAccountID: account.ID, AccountMask: account.AccountNoMask, ChannelID: channel.ID, ChannelCode: channel.Code, ChannelName: channel.Name, RequestAmount: input.Amount, FeeAmount: fee, ActualAmount: input.Amount - fee, Currency: input.Currency, MinimumAmount: minimum, MaximumAmount: maximum, AvailableAmount: wallet.AvailableAmount, TodayCount: wallet.TodayCount, DailyLimit: wallet.DailyLimit, QuoteExpiresAt: s.now().UTC().Add(quoteTTL).Format(time.RFC3339)}, account, channel, nil } func (s *Service) ensureWithdrawalEnabled(ctx context.Context, tenantID int64) error { raw, err := s.repository.CurrentSiteConfig(ctx, tenantID) if err != nil { return err } var config map[string]any if json.Unmarshal(raw, &config) != nil { config = map[string]any{} } if enabled, found := findBoolValue(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"); found && !enabled { return store.ErrWithdrawalDisabled } return nil } func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID int64, password string) (*store.AccountSecurity, error) { if !tradePasswordPattern.MatchString(strings.TrimSpace(password)) { return nil, ErrTradePassword } key := fmt.Sprintf("olons_server:finance:trade-password-rate:%d:%d", tenantID, memberID) attempts, err := s.redis.Get(ctx, key).Int() if err != nil && !errors.Is(err, redis.Nil) { return nil, ErrUnavailable } if attempts >= passwordMaxTry { return nil, ErrRateLimited } security, err := s.repository.AccountSecurity(ctx, tenantID, memberID) if err != nil { return nil, err } if security.TradePasswordHash == "" { return nil, ErrTradePasswordUnset } if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(password)) != nil { pipe := s.redis.TxPipeline() pipe.Incr(ctx, key) pipe.Expire(ctx, key, 15*time.Minute) if _, rateErr := pipe.Exec(ctx); rateErr != nil { return nil, ErrUnavailable } return nil, ErrTradePassword } if err := s.redis.Del(ctx, key).Err(); err != nil { return nil, ErrUnavailable } return security, nil } func normalizePaymentAccount(input *PaymentAccountInput) error { input.BankName = strings.TrimSpace(input.BankName) input.HolderName = strings.TrimSpace(input.HolderName) input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.AccountNo)) input.DigitalRMBCode = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.DigitalRMBCode)) if !validHumanName(input.HolderName) || utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 || len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) || (input.DigitalRMBCode != "" && (len(input.DigitalRMBCode) < 6 || len(input.DigitalRMBCode) > 32 || !allDigits(input.DigitalRMBCode))) || input.WechatQRAttachmentID <= 0 || input.AlipayQRAttachmentID <= 0 || input.WechatQRAttachmentID == input.AlipayQRAttachmentID { return ErrAccountFieldsInvalid } return nil } func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount { return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode, BankName: row.BankName, HolderMask: row.HolderMask, AccountNoMask: row.AccountNoMask, DigitalRMBCodeMask: row.DigitalRMBCodeMask, WechatQRAttachmentID: row.WechatQRAttachmentID, AlipayQRAttachmentID: row.AlipayQRAttachmentID, VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt} } func withdrawalFromStore(row store.WithdrawalOrder) Withdrawal { risk := row.RiskResult if len(risk) == 0 { risk = json.RawMessage(`{}`) } return Withdrawal{ID: row.ID, WithdrawalNo: row.WithdrawalNo, PaymentAccountID: row.PaymentAccountID, ChannelID: row.ChannelID, ChannelName: row.ChannelName, RequestAmount: row.RequestAmount, FeeAmount: row.FeeAmount, ActualAmount: row.ActualAmount, Currency: row.Currency, AccountMask: row.AccountMask, BankName: row.BankName, HolderMask: row.HolderMask, Status: withdrawalStatusName(row.Status), StatusText: withdrawalStatusText(row.Status), ReasonCode: row.ReviewReasonCode, ReasonText: row.ReviewReason, ExternalNo: row.ExternalNo, RiskResult: risk, ReviewedAt: row.ReviewedAt, ProcessedAt: row.ProcessedAt, CompletedAt: row.CompletedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, IdempotentReplay: row.Replayed} } func rechargeFromStore(row store.RechargeOrder) Recharge { vouchers := row.VoucherAttachmentIDs if len(vouchers) == 0 { vouchers = json.RawMessage(`[]`) } result := Recharge{ID: row.ID, RechargeNo: row.RechargeNo, ChannelID: row.ChannelID, ChannelName: row.ChannelName, RequestAmount: row.RequestAmount, PayableAmount: row.RequestAmount, PaidAmount: row.PaidAmount, Currency: row.Currency, VoucherAttachmentIDs: vouchers, Status: rechargeStatusName(row.Status), StatusText: rechargeStatusText(row.Status), ReasonCode: row.ReviewReasonCode, ReasonText: row.ReviewReason, Remark: row.Remark, PaymentInstructions: "沙箱订单,请按页面提示完成后续操作,不要向个人账户转账。", PaidAt: row.PaidAt, ReviewedAt: row.ReviewedAt, CreditedAt: row.CreditedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt} if row.Status == 1 && row.CreatedAt != "" { if created, err := time.Parse(time.RFC3339, row.CreatedAt); err == nil { result.ExpireAt = created.Add(30 * time.Minute).Format(time.RFC3339) } } return result } func rechargeStatusCode(value string) (int, bool) { switch strings.ToLower(strings.TrimSpace(value)) { case "", "all": return 0, true case "pending_payment": return 1, true case "reviewing", "processing": return 2, true case "succeeded": return 3, true case "failed": return 4, true case "expired": return 5, true case "cancelled": return 6, true default: return 0, false } } func rechargeStatusName(value int) string { names := map[int]string{1: "pending_payment", 2: "reviewing", 3: "succeeded", 4: "failed", 5: "expired", 6: "cancelled"} if name := names[value]; name != "" { return name } return "unknown" } func rechargeStatusText(value int) string { names := map[int]string{1: "待支付", 2: "审核中", 3: "已成功", 4: "失败", 5: "已过期", 6: "已取消"} if name := names[value]; name != "" { return name } return "未知状态" } func withdrawalFee(amount int64, channel store.WithdrawalChannel) (int64, error) { if amount <= 0 || channel.FeeRate < 0 || channel.FeeRate > 10000 || channel.FeeFixedAmount < 0 { return 0, store.ErrWithdrawalChannel } rate := int64(channel.FeeRate) rateFee := (amount/10000)*rate + ((amount%10000)*rate+9999)/10000 switch channel.FeeType { case 1: return channel.FeeFixedAmount, nil case 2: return rateFee, nil case 3: return channel.FeeFixedAmount + rateFee, nil default: return 0, store.ErrWithdrawalChannel } } func channelTypes(accountType int) []int { specific := map[int]int{1: 3, 2: 4, 3: 3, 4: 2, 5: 1}[accountType] if specific == 0 || specific == 4 { return []int{4} } return []int{specific, 4} } func accountTypeName(value int) string { names := map[int]string{1: "bank_card", 2: "digital_wallet", 3: "unionpay", 4: "alipay", 5: "wechat"} if name := names[value]; name != "" { return name } return "unknown" } func verifyStatusName(value int) string { names := map[int]string{1: "pending", 2: "verified", 3: "rejected"} if name := names[value]; name != "" { return name } return "unknown" } func withdrawalStatusCode(value string) (int, bool) { switch strings.ToLower(strings.TrimSpace(value)) { case "", "all": return 0, true case "pending_review": return 1, true case "reviewing": return 2, true case "processing": return 3, true case "succeeded": return 4, true case "failed": return 5, true case "rejected": return 6, true case "cancelled": return 7, true default: return 0, false } } func withdrawalStatusName(value int) string { names := map[int]string{1: "pending_review", 2: "reviewing", 3: "processing", 4: "succeeded", 5: "failed", 6: "rejected", 7: "cancelled"} if name := names[value]; name != "" { return name } return "unknown" } func withdrawalStatusText(value int) string { names := map[int]string{1: "待审核", 2: "审核中", 3: "处理中", 4: "已成功", 5: "失败", 6: "已驳回", 7: "已取消"} if name := names[value]; name != "" { return name } return "未知状态" } func (s *Service) encrypt(value string) (string, error) { nonce := make([]byte, s.aead.NonceSize()) if _, err := rand.Read(nonce); err != nil { return "", err } sealed := s.aead.Seal(nil, nonce, []byte(value), nil) return base64.RawStdEncoding.EncodeToString(append(nonce, sealed...)), nil } func (s *Service) decrypt(value string) (string, error) { payload, err := base64.RawStdEncoding.DecodeString(strings.TrimSpace(value)) if err != nil || len(payload) <= s.aead.NonceSize() { return "", errors.New("encrypted value is invalid") } aeads := append([]cipher.AEAD{s.aead}, s.legacyAEADs...) for _, aead := range aeads { if len(payload) <= aead.NonceSize() { continue } plain, decryptErr := aead.Open(nil, payload[:aead.NonceSize()], payload[aead.NonceSize():], nil) if decryptErr == nil { return string(plain), nil } } return "", errors.New("encrypted value cannot be decrypted") } func (s *Service) lookupHash(value string) string { mac := hmac.New(sha256.New, s.lookupKey) _, _ = mac.Write([]byte(value)) return hex.EncodeToString(mac.Sum(nil)) } func deterministicNumber(prefix string, tenantID, memberID int64, key string) string { digest := sha256.Sum256([]byte(strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10) + ":" + key + ":" + prefix)) return prefix + strings.ToUpper(hex.EncodeToString(digest[:16])) } func randomNumber(prefix string) (string, error) { random := make([]byte, 12) if _, err := rand.Read(random); err != nil { return "", err } return prefix + time.Now().UTC().Format("20060102150405") + strings.ToUpper(hex.EncodeToString(random)), nil } func greaterInt64(left, right int64) int64 { if left > right { return left } return right } func decodeKey(value string) ([]byte, error) { value = strings.TrimSpace(value) if decoded, err := base64.StdEncoding.DecodeString(value); err == nil { return decoded, nil } return base64.RawStdEncoding.DecodeString(value) } func maskPhone(value string) string { if len(value) != 11 { return "" } return value[:3] + "****" + value[7:] } func maskName(value string) string { runes := []rune(value) if len(runes) == 0 { return "" } if len(runes) == 1 { return string(runes[0]) + "*" } return string(runes[0]) + strings.Repeat("*", len(runes)-1) } func maskAccount(value string) string { runes := []rune(value) if len(runes) <= 4 { return strings.Repeat("*", len(runes)) } if len(runes) <= 6 { return string(runes[0]) + strings.Repeat("*", len(runes)-2) + string(runes[len(runes)-1]) } prefix := 4 if len(runes) < 12 { prefix = 2 } return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:]) } func validHumanName(value string) bool { count := utf8.RuneCountInString(value) if count < 2 || count > 64 { return false } for _, r := range value { if unicode.IsControl(r) || r == '<' || r == '>' { return false } } return true } func allDigits(value string) bool { for _, r := range value { if r < '0' || r > '9' { return false } } return value != "" } func findBoolValue(config map[string]any, keys ...string) (bool, bool) { for _, key := range keys { for candidate, value := range config { if strings.EqualFold(candidate, key) { switch typed := value.(type) { case bool: return typed, true case float64: return typed == 1, true case string: return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on"), true } return false, true } } } for _, value := range config { if nested, ok := value.(map[string]any); ok { if result, found := findBoolValue(nested, keys...); found { return result, true } } } return false, false }