商城c端前端
This commit is contained in:
+17
-13
@@ -3,17 +3,21 @@ 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")
|
||||
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")
|
||||
ErrRechargeStatusInvalid = errors.New("recharge status invalid")
|
||||
ErrRechargeOrderIDInvalid = errors.New("recharge order id invalid")
|
||||
ErrRemarkInvalid = errors.New("remark invalid")
|
||||
ErrVoucherInvalid = errors.New("voucher attachment ids invalid")
|
||||
)
|
||||
|
||||
@@ -51,6 +51,10 @@ type Repository interface {
|
||||
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)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@@ -153,6 +157,58 @@ type WithdrawalPage struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -357,6 +413,134 @@ func (s *Service) WithdrawalOrder(ctx context.Context, tenantID, memberID, order
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) {
|
||||
if !s.ready {
|
||||
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.ready {
|
||||
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.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 := 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.ready {
|
||||
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) quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) {
|
||||
quote, _, _, err := s.quoteData(ctx, tenantID, memberID, input)
|
||||
return quote, err
|
||||
@@ -537,6 +721,64 @@ func withdrawalFromStore(row store.WithdrawalOrder) Withdrawal {
|
||||
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
|
||||
@@ -650,6 +892,21 @@ func deterministicNumber(prefix string, tenantID, memberID int64, key string) st
|
||||
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 {
|
||||
|
||||
@@ -12,11 +12,14 @@ import (
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
config json.RawMessage
|
||||
account *store.PaymentAccount
|
||||
channel *store.WithdrawalChannel
|
||||
wallet *store.WithdrawalWallet
|
||||
orders []store.WithdrawalOrder
|
||||
config json.RawMessage
|
||||
account *store.PaymentAccount
|
||||
channel *store.WithdrawalChannel
|
||||
wallet *store.WithdrawalWallet
|
||||
orders []store.WithdrawalOrder
|
||||
rechargeChannels []store.RechargeChannel
|
||||
rechargeOrders []store.RechargeOrder
|
||||
createdRecharge store.RechargeCreation
|
||||
}
|
||||
|
||||
func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
|
||||
@@ -49,6 +52,21 @@ func (f *fakeRepository) WithdrawalOrders(context.Context, int64, int64, int, in
|
||||
func (f *fakeRepository) WithdrawalOrder(context.Context, int64, int64, int64) (*store.WithdrawalOrder, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (f *fakeRepository) RechargeChannels(context.Context, int64) ([]store.RechargeChannel, error) {
|
||||
return f.rechargeChannels, nil
|
||||
}
|
||||
func (f *fakeRepository) CreateRecharge(_ context.Context, input store.RechargeCreation) (*store.RechargeOrder, error) {
|
||||
f.createdRecharge = input
|
||||
return &store.RechargeOrder{ID: 8, RechargeNo: input.RechargeNo, ChannelID: input.ChannelID,
|
||||
ChannelName: "人工审核", RequestAmount: input.RequestAmount, Currency: input.Currency,
|
||||
Status: 1, Remark: input.Remark}, nil
|
||||
}
|
||||
func (f *fakeRepository) RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, error) {
|
||||
return &store.RechargePage{Items: f.rechargeOrders, Total: int64(len(f.rechargeOrders))}, nil
|
||||
}
|
||||
func (f *fakeRepository) RechargeOrder(context.Context, int64, int64, int64) (*store.RechargeOrder, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func TestQuoteCalculatesRateFeeAndLimits(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":true}}`),
|
||||
@@ -105,6 +123,40 @@ func TestWithdrawalStatusValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRechargeValidatesChannelAndMapsOrder(t *testing.T) {
|
||||
repository := &fakeRepository{rechargeChannels: []store.RechargeChannel{{
|
||||
ID: 4, Code: "manual", Name: "人工审核", MinAmount: 100, MaxAmount: 100000,
|
||||
}}}
|
||||
service := &Service{repository: repository, ready: true, now: time.Now}
|
||||
result, err := service.CreateRecharge(context.Background(), 0, 9, CreateRechargeInput{
|
||||
ChannelID: 4, Amount: 50000, Currency: "cny", Remark: "测试",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "pending_payment" || result.RequestAmount != 50000 ||
|
||||
repository.createdRecharge.MemberID != 9 || repository.createdRecharge.Currency != "CNY" {
|
||||
t.Fatalf("unexpected recharge: %#v, creation: %#v", result, repository.createdRecharge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRechargeOrdersMapsStatusAndPagination(t *testing.T) {
|
||||
repository := &fakeRepository{rechargeOrders: []store.RechargeOrder{{
|
||||
ID: 3, RechargeNo: "RC3", RequestAmount: 20000, Currency: "CNY", Status: 3,
|
||||
}}}
|
||||
service := &Service{repository: repository, ready: true, now: time.Now}
|
||||
result, err := service.RechargeOrders(context.Background(), 0, 9, 1, 20, "succeeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Total != 1 || len(result.Items) != 1 || result.Items[0].Status != "succeeded" || result.HasNext {
|
||||
t.Fatalf("unexpected recharge page: %#v", result)
|
||||
}
|
||||
if _, err = service.RechargeOrders(context.Background(), 0, 9, 1, 20, "invalid"); !errors.Is(err, ErrRechargeStatusInvalid) {
|
||||
t.Fatalf("expected recharge 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") {
|
||||
|
||||
Reference in New Issue
Block a user