1135 lines
38 KiB
Go
1135 lines
38 KiB
Go
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}$`)
|
|
identityNoPattern = regexp.MustCompile(`^[1-9][0-9]{14}(?:[0-9]{2}[0-9X])?$`)
|
|
)
|
|
|
|
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)
|
|
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
|
|
lookupKey []byte
|
|
ready bool
|
|
now func() time.Time
|
|
}
|
|
|
|
type PaymentAccountInput struct {
|
|
BankName string `json:"bank_name"`
|
|
HolderName string `json:"holder_name"`
|
|
IdentityNo string `json:"identity_no"`
|
|
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"`
|
|
IdentityNoMask string `json:"identity_no_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"`
|
|
IdentityNo string `json:"identity_no"`
|
|
AccountNo string `json:"account_no"`
|
|
DigitalRMBCode string `json:"digital_rmb_code"`
|
|
WechatQRURL string `json:"wechat_qr_url"`
|
|
AlipayQRURL string `json:"alipay_qr_url"`
|
|
VerifyStatus string `json:"verify_status"`
|
|
IsDefault bool `json:"is_default"`
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
block, err := aes.NewCipher(encryptionKey)
|
|
if err != nil {
|
|
return service
|
|
}
|
|
aead, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return service
|
|
}
|
|
service.aead = aead
|
|
service.lookupKey = lookupKey
|
|
service.ready = repository != nil && redisClient != nil
|
|
return service
|
|
}
|
|
|
|
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.ready || 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, err := s.decrypt(row.HolderCipher)
|
|
if err != nil {
|
|
return nil, store.ErrPaymentAccountInvalid
|
|
}
|
|
accountNo, err := s.decrypt(row.AccountNoCipher)
|
|
if err != nil {
|
|
return nil, store.ErrPaymentAccountInvalid
|
|
}
|
|
identityNo := ""
|
|
if row.IdentityNoCipher != "" {
|
|
identityNo, err = s.decrypt(row.IdentityNoCipher)
|
|
if err != nil {
|
|
return nil, store.ErrPaymentAccountInvalid
|
|
}
|
|
}
|
|
digitalRMBCode := ""
|
|
if row.DigitalRMBCodeCipher != "" {
|
|
digitalRMBCode, err = s.decrypt(row.DigitalRMBCodeCipher)
|
|
if err != nil {
|
|
return nil, store.ErrPaymentAccountInvalid
|
|
}
|
|
}
|
|
items = append(items, PaymentAccountDetail{
|
|
ID: row.ID, BankName: row.BankName, HolderName: holderName,
|
|
IdentityNo: identityNo, AccountNo: accountNo, DigitalRMBCode: digitalRMBCode,
|
|
WechatQRURL: row.WechatQRURL, AlipayQRURL: row.AlipayQRURL,
|
|
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) {
|
|
if !s.ready {
|
|
return nil, ErrUnavailable
|
|
}
|
|
if err := normalizePaymentAccount(&input); err != nil {
|
|
return nil, err
|
|
}
|
|
holderCipher, err := s.encrypt(input.HolderName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
identityNoCipher, err := s.encrypt(input.IdentityNo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
accountCipher, err := s.encrypt(input.AccountNo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
digitalRMBCodeCipher, digitalRMBCodeHash, digitalRMBCodeMask := "", "", ""
|
|
if input.DigitalRMBCode != "" {
|
|
digitalRMBCodeCipher, err = s.encrypt(input.DigitalRMBCode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
digitalRMBCodeHash = s.lookupHash(input.DigitalRMBCode)
|
|
digitalRMBCodeMask = maskAccount(input.DigitalRMBCode)
|
|
}
|
|
row, err := s.repository.CreatePaymentAccount(ctx, store.PaymentAccountCreation{
|
|
TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName,
|
|
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName),
|
|
IdentityNoCipher: identityNoCipher, IdentityNoHash: s.lookupHash(input.IdentityNo),
|
|
IdentityNoMask: maskIdentityNo(input.IdentityNo), AccountNoCipher: accountCipher,
|
|
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
|
|
DigitalRMBCodeCipher: digitalRMBCodeCipher, DigitalRMBCodeHash: digitalRMBCodeHash,
|
|
DigitalRMBCodeMask: digitalRMBCodeMask,
|
|
WechatQRAttachmentID: input.WechatQRAttachmentID,
|
|
AlipayQRAttachmentID: input.AlipayQRAttachmentID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := paymentAccountFromStore(*row)
|
|
return &result, 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
|
|
}
|
|
identityNo := ""
|
|
if account.IdentityNoCipher != "" {
|
|
identityNo, err = s.decrypt(account.IdentityNoCipher)
|
|
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,
|
|
"identity_no": identityNo, "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 || !findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL") {
|
|
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.IdentityNo = strings.ToUpper(strings.NewReplacer(" ", "", "-", "").Replace(input.IdentityNo))
|
|
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.AccountNo))
|
|
input.DigitalRMBCode = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.DigitalRMBCode))
|
|
if !validHumanName(input.HolderName) || !identityNoPattern.MatchString(input.IdentityNo) ||
|
|
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, IdentityNoMask: row.IdentityNoMask,
|
|
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")
|
|
}
|
|
plain, err := s.aead.Open(nil, payload[:s.aead.NonceSize()], payload[s.aead.NonceSize():], nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(plain), nil
|
|
}
|
|
|
|
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 maskIdentityNo(value string) string {
|
|
if len(value) <= 8 {
|
|
return strings.Repeat("*", len(value))
|
|
}
|
|
return value[:4] + strings.Repeat("*", len(value)-8) + value[len(value)-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 findBool(config map[string]any, keys ...string) bool {
|
|
for _, key := range keys {
|
|
for candidate, value := range config {
|
|
if strings.EqualFold(candidate, key) {
|
|
switch typed := value.(type) {
|
|
case bool:
|
|
return typed
|
|
case float64:
|
|
return typed == 1
|
|
case string:
|
|
return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, value := range config {
|
|
if nested, ok := value.(map[string]any); ok && findBool(nested, keys...) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|