feat: migrate workbench services

This commit is contained in:
richjohnrambo
2026-10-10 23:34:06 +08:00
parent c13fb72849
commit 81e3cf1d03
88 changed files with 7358 additions and 15333 deletions
+19
View File
@@ -0,0 +1,19 @@
package finance
import "errors"
var (
ErrUnavailable = errors.New("finance service unavailable")
ErrAccountTypeInvalid = errors.New("payment account type invalid")
ErrAccountFieldsInvalid = errors.New("payment account fields invalid")
ErrTradePassword = errors.New("trade password invalid")
ErrTradePasswordUnset = errors.New("trade password is not set")
ErrRateLimited = errors.New("trade password attempts rate limited")
ErrCurrencyInvalid = errors.New("currency invalid")
ErrAmountInvalid = errors.New("amount invalid")
ErrIdempotencyKey = errors.New("idempotency key invalid")
ErrPageInvalid = errors.New("page invalid")
ErrPageSizeInvalid = errors.New("page size invalid")
ErrStatusInvalid = errors.New("withdrawal status invalid")
ErrOrderIDInvalid = errors.New("withdrawal order id invalid")
)
+737
View File
@@ -0,0 +1,737 @@
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/im_workbench/internal/config"
"git.118im.com/im_team/im_workbench/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}$`)
phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
bankCodePattern = regexp.MustCompile(`^[A-Z0-9_-]{2,32}$`)
accountTokenPattern = regexp.MustCompile(`^[A-Za-z0-9@._+\-]{5,64}$`)
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)
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)
}
type Service struct {
repository Repository
redis *redis.Client
aead cipher.AEAD
lookupKey []byte
ready bool
now func() time.Time
}
type PaymentAccountInput struct {
AccountType string `json:"account_type"`
BankCode string `json:"bank_code"`
BankName string `json:"bank_name"`
HolderName string `json:"holder_name"`
AccountNo string `json:"account_no"`
ReservedPhone string `json:"reserved_phone"`
QRAttachmentID int64 `json:"qr_attachment_id"`
TradePassword string `json:"trade_password"`
}
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"`
MobileMask string `json:"mobile_mask"`
AccountNoMask string `json:"account_no_mask"`
QRAttachmentID int64 `json:"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 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"`
}
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
service := &Service{repository: repository, redis: redisClient, now: time.Now}
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 (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
if !s.ready {
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) CreatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) {
if !s.ready {
return nil, ErrUnavailable
}
accountType, err := normalizePaymentAccount(&input)
if err != nil {
return nil, err
}
security, err := s.verifyTradePassword(ctx, tenantID, memberID, input.TradePassword)
if err != nil {
return nil, err
}
holderCipher, err := s.encrypt(input.HolderName)
if err != nil {
return nil, err
}
accountCipher, err := s.encrypt(input.AccountNo)
if err != nil {
return nil, err
}
mobileCipher := ""
if input.ReservedPhone != "" {
mobileCipher, err = s.encrypt("+86" + input.ReservedPhone)
if err != nil {
return nil, err
}
}
row, err := s.repository.CreatePaymentAccount(ctx, store.PaymentAccountCreation{
TenantID: tenantID, MemberID: memberID, ExpectedTradePasswordHash: security.TradePasswordHash,
AccountType: accountType, BankCode: input.BankCode, BankName: input.BankName,
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName), MobileCipher: mobileCipher,
MobileMask: maskPhone(input.ReservedPhone), AccountNoCipher: accountCipher,
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
QRAttachmentID: input.QRAttachmentID,
})
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
}
reservedPhone := ""
if account.MobileCipher != "" {
reservedPhone, err = s.decrypt(account.MobileCipher)
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,
"reserved_phone": reservedPhone, "account_no": accountNo,
"qr_attachment_id": account.QRAttachmentID,
})
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,
ExpectedMobileCipher: account.MobileCipher, ExpectedAccountNoCipher: account.AccountNoCipher,
ExpectedAccountNoHash: account.AccountNoHash, ExpectedQRAttachmentID: account.QRAttachmentID,
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.ready {
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.ready {
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) 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("workbench: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) (int, error) {
input.AccountType = strings.ToLower(strings.TrimSpace(input.AccountType))
input.BankCode = strings.ToUpper(strings.TrimSpace(input.BankCode))
input.BankName = strings.TrimSpace(input.BankName)
input.HolderName = strings.TrimSpace(input.HolderName)
input.AccountNo = strings.TrimSpace(input.AccountNo)
input.ReservedPhone = strings.TrimSpace(input.ReservedPhone)
if !validHumanName(input.HolderName) || (input.ReservedPhone != "" && !phonePattern.MatchString(input.ReservedPhone)) || input.QRAttachmentID < 0 {
return 0, ErrAccountFieldsInvalid
}
switch input.AccountType {
case "bank_card":
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(input.AccountNo)
if !bankCodePattern.MatchString(input.BankCode) || utf8.RuneCountInString(input.BankName) < 2 ||
utf8.RuneCountInString(input.BankName) > 100 || len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) {
return 0, ErrAccountFieldsInvalid
}
return 1, nil
case "digital_wallet":
if !accountTokenPattern.MatchString(input.AccountNo) {
return 0, ErrAccountFieldsInvalid
}
return 2, nil
case "unionpay":
if !accountTokenPattern.MatchString(input.AccountNo) {
return 0, ErrAccountFieldsInvalid
}
return 3, nil
case "alipay":
input.BankName = "支付宝"
input.BankCode = "ALIPAY"
if !accountTokenPattern.MatchString(input.AccountNo) {
return 0, ErrAccountFieldsInvalid
}
return 4, nil
case "wechat":
input.BankName = "微信"
input.BankCode = "WECHAT"
if !accountTokenPattern.MatchString(input.AccountNo) {
return 0, ErrAccountFieldsInvalid
}
return 5, nil
default:
return 0, ErrAccountTypeInvalid
}
}
func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount {
return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode,
BankName: row.BankName, HolderMask: row.HolderMask, MobileMask: row.MobileMask,
AccountNoMask: row.AccountNoMask, QRAttachmentID: row.QRAttachmentID,
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 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 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 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
}
+128
View File
@@ -0,0 +1,128 @@
package finance
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/im_workbench/internal/store"
)
type fakeRepository struct {
config json.RawMessage
account *store.PaymentAccount
channel *store.WithdrawalChannel
wallet *store.WithdrawalWallet
orders []store.WithdrawalOrder
}
func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
return f.config, nil
}
func (f *fakeRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) {
return nil, errors.New("not implemented")
}
func (f *fakeRepository) PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error) {
return nil, nil
}
func (f *fakeRepository) CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error) {
return nil, errors.New("not implemented")
}
func (f *fakeRepository) PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error) {
return f.account, nil
}
func (f *fakeRepository) ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error) {
return f.channel, nil
}
func (f *fakeRepository) WithdrawalWallet(context.Context, int64, int64) (*store.WithdrawalWallet, error) {
return f.wallet, nil
}
func (f *fakeRepository) CreateWithdrawal(context.Context, store.WithdrawalCreation) (*store.WithdrawalOrder, error) {
return nil, errors.New("not implemented")
}
func (f *fakeRepository) WithdrawalOrders(context.Context, int64, int64, int, int, int) (*store.WithdrawalPage, error) {
return &store.WithdrawalPage{Items: f.orders, Total: int64(len(f.orders))}, nil
}
func (f *fakeRepository) WithdrawalOrder(context.Context, int64, int64, int64) (*store.WithdrawalOrder, error) {
return nil, errors.New("not implemented")
}
func TestQuoteCalculatesRateFeeAndLimits(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":true}}`),
account: &store.PaymentAccount{ID: 3, AccountType: 1, AccountNoMask: "6222********1234", VerifyStatus: 2},
channel: &store.WithdrawalChannel{ID: 1, Code: "bank", Name: "银行卡", ChannelType: 3,
MinAmount: 1000, MaxAmount: 100000, FeeType: 3, FeeFixedAmount: 100, FeeRate: 25},
wallet: &store.WithdrawalWallet{AvailableAmount: 80000, AccountStatus: 1, DailyLimit: 3, TodayCount: 1}}
service := &Service{repository: repository, ready: true, now: func() time.Time {
return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)
}}
result, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 3, Amount: 50000, Currency: "cny"})
if err != nil {
t.Fatal(err)
}
if result.FeeAmount != 225 || result.ActualAmount != 49775 || result.MaximumAmount != 80000 ||
result.QuoteExpiresAt != "2026-10-09T02:05:00Z" {
t.Fatalf("unexpected quote: %#v", result)
}
}
func TestQuoteFailsClosed(t *testing.T) {
service := &Service{repository: &fakeRepository{config: json.RawMessage(`{}`)}, ready: true, now: time.Now}
_, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 1, Amount: 100, Currency: "CNY"})
if !errors.Is(err, store.ErrWithdrawalDisabled) {
t.Fatalf("expected disabled error, got %v", err)
}
}
func TestNormalizePaymentAccount(t *testing.T) {
input := PaymentAccountInput{AccountType: "bank_card", BankCode: "icbc", BankName: "中国工商银行",
HolderName: "张三", AccountNo: "6222 0000-0000 1234", ReservedPhone: "13800138000"}
accountType, err := normalizePaymentAccount(&input)
if err != nil {
t.Fatal(err)
}
if accountType != 1 || input.BankCode != "ICBC" || input.AccountNo != "6222000000001234" ||
maskAccount(input.AccountNo) != "6222********1234" {
t.Fatalf("unexpected normalized input: %#v", input)
}
}
func TestMaskAccountShortValueDoesNotLeak(t *testing.T) {
if got := maskAccount("abcde"); got != "a***e" {
t.Fatalf("unexpected mask %q", got)
}
}
func TestWithdrawalStatusValidation(t *testing.T) {
service := &Service{repository: &fakeRepository{}, ready: true, now: time.Now}
_, err := service.WithdrawalOrders(context.Background(), 0, 1, 1, 20, "bad")
if !errors.Is(err, ErrStatusInvalid) {
t.Fatalf("expected status error, got %v", err)
}
}
func TestDeterministicNumberIsStableAndScoped(t *testing.T) {
first := deterministicNumber("WD", 0, 1, "request-key")
if first != deterministicNumber("WD", 0, 1, "request-key") || first == deterministicNumber("WD", 0, 2, "request-key") {
t.Fatal("deterministic number is not stable or member scoped")
}
}
func TestEncryptionRoundTrip(t *testing.T) {
service := New(&fakeRepository{}, nil, config.AuthConfig{
DataEncryptionKey: "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
LookupHMACKey: "ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=",
})
ciphertext, err := service.encrypt("6222000000001234")
if err != nil {
t.Fatal(err)
}
plain, err := service.decrypt(ciphertext)
if err != nil || plain != "6222000000001234" {
t.Fatalf("unexpected round trip: %q, %v", plain, err)
}
}