diff --git a/backend/api/etc/platform_dev.yaml b/backend/api/etc/platform_dev.yaml index 2177538..4ac0d56 100644 --- a/backend/api/etc/platform_dev.yaml +++ b/backend/api/etc/platform_dev.yaml @@ -10,3 +10,6 @@ Databases: Cache: redis://default:change-me@127.0.0.1:6379/0 OnMicroService: false SecretKey: change-me-to-a-random-string + +Wallet: + ManualRechargeMaxAmount: 100000000 diff --git a/backend/api/internal/config/config.go b/backend/api/internal/config/config.go index c771102..5485af7 100644 --- a/backend/api/internal/config/config.go +++ b/backend/api/internal/config/config.go @@ -10,12 +10,18 @@ import ( // Spec 是 Platform API 的运行配置。 var Spec SrvConfig +// WalletConfig 保存钱包后台资金操作限制。 +type WalletConfig struct { + ManualRechargeMaxAmount int64 `yaml:"ManualRechargeMaxAmount"` +} + // SrvConfig 与 sample/server 配置结构保持一致。 type SrvConfig struct { conf.Base `yaml:",inline"` Databases *conf.DBConf `yaml:"Databases"` Rpc map[string]conf.RpcConf `yaml:"Rpc"` Apm *conf.ApmConf `yaml:"APM"` + Wallet WalletConfig `yaml:"Wallet"` } // New 初始化 BSM 配置并校验服务监听地址。 @@ -25,5 +31,8 @@ func New(srvKey string) { Spec.BindIP = conf.CheckIP(Spec.BindIP) Spec.Addr = net.JoinHostPort(Spec.BindIP, Spec.Port) conf.NotNil(Spec.Service, Spec.Cache) + if Spec.Wallet.ManualRechargeMaxAmount <= 0 { + panic("Wallet.ManualRechargeMaxAmount must be greater than zero") + } conf.PrintInfo(Spec.Addr) } diff --git a/backend/api/internal/logic/platform/platform.go b/backend/api/internal/logic/platform/platform.go index c3a0da0..ba67b85 100644 --- a/backend/api/internal/logic/platform/platform.go +++ b/backend/api/internal/logic/platform/platform.go @@ -70,6 +70,10 @@ var keywordSafeColumns = map[string]bool{ "ticket_no": true, "category": true, "priority": true, "platform_role_code": true, "data_scope": true, "menu_code": true, "path": true, "resource_type": true, + "owner_type": true, "owner_identity": true, "payment_no": true, + "record_no": true, "request_no": true, "refund_no": true, + "cash_no": true, "trade_no": true, "trade_type": true, + "pay_channel": true, "payment_type": true, } func applyKeywordFilter(ctx *gin.Context, query *gorm.DB, model any) *gorm.DB { diff --git a/backend/api/internal/logic/platform/resource.go b/backend/api/internal/logic/platform/resource.go index f8106f3..328e064 100644 --- a/backend/api/internal/logic/platform/resource.go +++ b/backend/api/internal/logic/platform/resource.go @@ -83,7 +83,7 @@ func ExpectedResources() []ResourceContract { resourceContract("finance", "fin_payment", Writable, "list"), resourceContract("finance", "fin_settlement", Writable, "list"), resourceContract("finance", "fin_reconciliation", Writable, "list"), resourceContract("content", "cms_content", Writable, "list"), resourceContract("customer_service", "cs_ticket", Writable, "list"), resourceContract("platform", "platfrom_account", Writable, "list"), resourceContract("platform", "platform_role", Writable, "list"), resourceContract("platform", "platform_menu", Writable, "tree"), - resourceContract("wallet", "wallet", ReadOnly, "list"), resourceContract("wallet", "wallet_ledger", ReadOnly, "list"), resourceContract("wallet", "wallet_recharge", ReadOnly, "list"), resourceContract("wallet", "wallet_withdrawal", ReadOnly, "list"), + resourceContract("wallet", "wallet_basic", ReadOnly, "list"), resourceContract("wallet", "wallet_bank", ReadOnly, "list"), resourceContract("wallet", "wallet_payment", ReadOnly, "list"), resourceContract("wallet", "wallet_record", ReadOnly, "list"), resourceContract("wallet", "wallet_refund", ReadOnly, "list"), resourceContract("wallet", "wallet_apply_cash", ReadOnly, "list"), } } @@ -92,7 +92,7 @@ func resourceContract(domain, name string, mode ResourceMode, pageKind string) R } func resourcePath(domain, name string) string { - if domain == "product" { + if domain == "product" || domain == "wallet" { return "/" + name } switch name { diff --git a/backend/api/internal/logic/platform/resource_test.go b/backend/api/internal/logic/platform/resource_test.go index 039adc5..dd4d151 100644 --- a/backend/api/internal/logic/platform/resource_test.go +++ b/backend/api/internal/logic/platform/resource_test.go @@ -26,7 +26,7 @@ import ( func TestExpectedResources(t *testing.T) { assertContract(t, ExpectedResources(), "gas", "gas_basic", Writable, "list") assertContract(t, ExpectedResources(), "ec", "ec_order_item", Writable, "list") - assertContract(t, ExpectedResources(), "wallet", "wallet_ledger", ReadOnly, "list") + assertContract(t, ExpectedResources(), "wallet", "wallet_record", ReadOnly, "list") assertContract(t, ExpectedResources(), "delivery", "delivery_track_point", ReadOnly, "list") assertContract(t, ExpectedResources(), "product", "product_info", Editable, "list") assertContract(t, ExpectedResources(), "product", "product_owner", AppendOnly, "list") diff --git a/backend/api/internal/logic/platform/task4_resources.go b/backend/api/internal/logic/platform/task4_resources.go index 99cd41f..67bea30 100644 --- a/backend/api/internal/logic/platform/task4_resources.go +++ b/backend/api/internal/logic/platform/task4_resources.go @@ -389,7 +389,10 @@ var relationIdentityModels = map[string]any{ "delivery_track_id": &models.DeliveryTrack{}, "platform_role_id": &models.PlatformRole{}, "platform_menu_id": &models.PlatformMenu{}, - "wallet_id": &models.Wallet{}, + "wallet_basic_id": &models.WalletBasic{}, + "wallet_payment_id": &models.WalletPayment{}, + "wallet_bank_id": &models.WalletBank{}, + "related_record_id": &models.WalletRecord{}, } var relationIdentityKeys = map[string]string{ diff --git a/backend/api/internal/logic/platform/wallet.go b/backend/api/internal/logic/platform/wallet.go new file mode 100644 index 0000000..222a1f5 --- /dev/null +++ b/backend/api/internal/logic/platform/wallet.go @@ -0,0 +1,323 @@ +package platform + +import ( + "errors" + "math" + "strconv" + "strings" + "time" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/bsm-sdk/core/middleware" + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var walletOwnerModels = map[string]any{ + "user": &models.UserAccount{}, + "staff": &models.StaffAccount{}, + "delivery": &models.DeliveryBasic{}, + "gas": &models.GasBasic{}, +} + +func ListWalletBasic(ctx *gin.Context) { listWalletPage[models.WalletBasic](ctx) } +func GetWalletBasic(ctx *gin.Context) { getWalletByIdentity[models.WalletBasic](ctx) } +func ListWalletBank(ctx *gin.Context) { listWalletPage[models.WalletBank](ctx) } +func GetWalletBank(ctx *gin.Context) { getWalletByIdentity[models.WalletBank](ctx) } +func ListWalletPayment(ctx *gin.Context) { listWalletPage[models.WalletPayment](ctx) } +func GetWalletPayment(ctx *gin.Context) { getWalletByIdentity[models.WalletPayment](ctx) } +func ListWalletRecord(ctx *gin.Context) { listWalletPage[models.WalletRecord](ctx) } +func GetWalletRecord(ctx *gin.Context) { getWalletByIdentity[models.WalletRecord](ctx) } +func ListWalletRefund(ctx *gin.Context) { listWalletPage[models.WalletRefund](ctx) } +func GetWalletRefund(ctx *gin.Context) { getWalletByIdentity[models.WalletRefund](ctx) } +func ListWalletApplyCash(ctx *gin.Context) { listWalletPage[models.WalletApplyCash](ctx) } +func GetWalletApplyCash(ctx *gin.Context) { getWalletByIdentity[models.WalletApplyCash](ctx) } + +func listWalletPage[T any](ctx *gin.Context) { + page, size := pageSize(ctx) + var list []T + var total int64 + model := new(T) + query := applyKeywordFilter(ctx, impl.DBService.Model(model), model) + if err := query.Count(&total).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if err := query.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + response, err := publicResourceResponse(list) + if err != nil { + infra.Response.Error(ctx, err) + return + } + protectWalletResponse(response, false) + infra.Response.Success(ctx, gin.H{"total": total, "list": protectPreciseLocation(ctx, model, response)}) +} + +func getWalletByIdentity[T any](ctx *gin.Context) { + var data T + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&data).Error; err != nil { + respondRecordError(ctx, err) + return + } + response, err := publicResourceResponse(data) + if err != nil { + infra.Response.Error(ctx, err) + return + } + protectWalletResponse(response, true) + infra.Response.Success(ctx, protectPreciseLocation(ctx, new(T), response)) +} + +func protectWalletResponse(value any, includeDetails bool) { + switch data := value.(type) { + case map[string]any: + for _, key := range []string{"alipay_id", "alipay_name", "wxpay_id", "wxpay_name"} { + if text, ok := data[key].(string); ok && text != "" { + data[key] = maskWalletAccount(text) + } + } + if last4, ok := data["card_no_last4"].(string); ok && last4 != "" { + data["card_no_masked"] = "****" + last4 + delete(data, "card_no_last4") + } + if owner, ok := data["card_owner"].(string); ok && owner != "" { + data["card_owner"] = maskPersonalNameValue(owner) + } + if !includeDetails { + delete(data, "args") + delete(data, "callback_msg") + delete(data, "order_info") + delete(data, "result") + } + for _, item := range data { + protectWalletResponse(item, includeDetails) + } + case []any: + for _, item := range data { + protectWalletResponse(item, includeDetails) + } + } +} + +func maskWalletAccount(value string) string { + runes := []rune(value) + if len(runes) <= 2 { + return strings.Repeat("*", len(runes)) + } + return string(runes[0]) + strings.Repeat("*", len(runes)-2) + string(runes[len(runes)-1]) +} + +func GetOrCreateOwnerWallet(ctx *gin.Context) { + ownerType := strings.ToLower(strings.TrimSpace(ctx.Param("owner_type"))) + ownerIdentity := strings.TrimSpace(ctx.Param("owner_identity")) + ownerID, err := resolveWalletOwner(ownerType, ownerIdentity) + if err != nil { + respondRecordError(ctx, err) + return + } + var wallet models.WalletBasic + err = impl.DBService.Transaction(func(tx *gorm.DB) error { + err := tx.Where("owner_type = ? AND owner_identity = ?", ownerType, ownerIdentity).First(&wallet).Error + if err == nil { + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + candidate := models.WalletBasic{ + Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, + OwnerType: ownerType, OwnerID: ownerID, OwnerIdentity: ownerIdentity, + } + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&candidate).Error; err != nil { + return err + } + return tx.Where("owner_type = ? AND owner_identity = ?", ownerType, ownerIdentity).First(&wallet).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + response, err := publicResourceResponse(wallet) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, protectPreciseLocation(ctx, &models.WalletBasic{}, response)) +} + +func resolveWalletOwner(ownerType, ownerIdentity string) (uint64, error) { + if ownerType == "platform" { + if ownerIdentity != "heqi" { + return 0, errors.New("invalid platform owner") + } + return 0, nil + } + model := walletOwnerModels[ownerType] + if model == nil || ownerIdentity == "" { + return 0, errors.New("invalid wallet owner") + } + return resolveIdentityID(model, ownerIdentity, true) +} + +func UpdateWalletBasicStatus(ctx *gin.Context) { + var request struct { + Status string `json:"status" binding:"required"` + } + if err := ctx.ShouldBindJSON(&request); err != nil || (request.Status != "enabled" && request.Status != "disabled") { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.WalletBasic{}, gin.H{"status": request.Status}, []string{"status"}) +} + +func RechargeWalletBasic(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } + var request struct { + RequestNo string `json:"request_no" binding:"required,max=128"` + Amount int64 `json:"amount" binding:"required"` + Withdrawable bool `json:"withdrawable"` + Reason string `json:"reason" binding:"required,max=1000"` + Remark string `json:"remark" binding:"max=2000"` + } + if err := ctx.ShouldBindJSON(&request); err != nil || + request.Amount <= 0 || request.Amount > config.Spec.Wallet.ManualRechargeMaxAmount || + strings.TrimSpace(request.RequestNo) == "" || strings.TrimSpace(request.Reason) == "" { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + operatorIdentity, operatorName := walletOperator(ctx) + var record models.WalletRecord + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("request_no = ?", request.RequestNo).First(&record).Error; err == nil { + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + var wallet models.WalletBasic + if err := tx.Where("identity = ?", ctx.Param("identity")).First(&wallet).Error; err != nil { + return err + } + updates := map[string]any{"balance": gorm.Expr("balance + ?", request.Amount)} + query := tx.Model(&models.WalletBasic{}). + Where("id = ? AND status = ? AND balance <= ?", wallet.ID, "enabled", math.MaxInt64-request.Amount) + if request.Withdrawable { + updates["withdrawal_balance"] = gorm.Expr("withdrawal_balance + ?", request.Amount) + query = query.Where("withdrawal_balance <= ?", math.MaxInt64-request.Amount) + } + result := query.Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return errors.New("wallet is disabled or balance overflow") + } + if err := tx.Where("id = ?", wallet.ID).First(&wallet).Error; err != nil { + return err + } + now := time.Now() + record = models.WalletRecord{ + Entity: models.Entity{Identity: models.NewIdentity(), Status: "posted", Version: 1}, + WalletBasicID: wallet.ID, RecordNo: models.NewIdentity(), RequestNo: request.RequestNo, + Direction: "income", TradeType: "recharge", Amount: request.Amount, + BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance, + InTradeNo: request.RequestNo, PayChannel: "manual", + OperatorIdentity: operatorIdentity, OperatorName: operatorName, + Ymd: dateNumber(now, "20060102"), Ym: dateNumber(now, "200601"), + Remark: strings.TrimSpace(request.Reason + " " + request.Remark), + } + return tx.Create(&record).Error + }) + if err != nil { + respondRecordError(ctx, err) + return + } + response, err := publicResourceResponse(record) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, response) +} + +func ApproveWalletApplyCash(ctx *gin.Context) { + reviewWalletApplyCash(ctx, "approved") +} + +func RejectWalletApplyCash(ctx *gin.Context) { + reviewWalletApplyCash(ctx, "rejected") +} + +func reviewWalletApplyCash(ctx *gin.Context, targetStatus string) { + if !requirePlatformRoot(ctx) { + return + } + var request struct { + Reason string `json:"reason" binding:"required,max=2000"` + } + if err := ctx.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.Reason) == "" { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + operatorIdentity, operatorName := walletOperator(ctx) + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var application models.WalletApplyCash + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("identity = ?", ctx.Param("identity")).First(&application).Error; err != nil { + return err + } + if application.Status == targetStatus { + return nil + } + if application.Status != "pending" { + return errors.New("cash application is not pending") + } + now := time.Now() + if targetStatus == "rejected" { + result := tx.Model(&models.WalletBasic{}). + Where("id = ? AND withdrawal_balance <= ?", application.WalletBasicID, math.MaxInt64-application.Amount). + Update("withdrawal_balance", gorm.Expr("withdrawal_balance + ?", application.Amount)) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return errors.New("withdrawal balance overflow") + } + } + return tx.Model(&application).Updates(map[string]any{ + "status": targetStatus, "reviewer_identity": operatorIdentity, "reviewer_name": operatorName, + "reviewed_at": &now, "review_reason": request.Reason, + }).Error + }) + if err != nil { + respondRecordError(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"updated": true, "status": targetStatus}) +} + +func walletOperator(ctx *gin.Context) (string, string) { + claims, err := middleware.ParseAuth(ctx) + if err != nil { + return "", "" + } + var account models.PlatfromAccount + if err := impl.DBService.Select("display_name").Where("identity = ?", claims.Identity).First(&account).Error; err != nil { + return claims.Identity, "" + } + return claims.Identity, account.DisplayName +} + +func dateNumber(value time.Time, layout string) int32 { + number, _ := strconv.ParseInt(value.Format(layout), 10, 32) + return int32(number) +} diff --git a/backend/api/internal/logic/platform/wallet_test.go b/backend/api/internal/logic/platform/wallet_test.go new file mode 100644 index 0000000..321de6f --- /dev/null +++ b/backend/api/internal/logic/platform/wallet_test.go @@ -0,0 +1,63 @@ +package platform + +import ( + "testing" + "time" +) + +func TestResolveWalletOwnerAcceptsOnlyStablePlatformOwner(t *testing.T) { + id, err := resolveWalletOwner("platform", "heqi") + if err != nil || id != 0 { + t.Fatalf("platform wallet owner = (%d, %v)", id, err) + } + if _, err := resolveWalletOwner("platform", "admin"); err == nil { + t.Fatal("arbitrary platform owner was accepted") + } + if _, err := resolveWalletOwner("unknown", "identity"); err == nil { + t.Fatal("unknown wallet owner type was accepted") + } +} + +func TestDateNumberBuildsWalletRecordIndexes(t *testing.T) { + value := time.Date(2026, time.July, 28, 10, 30, 0, 0, time.UTC) + if got := dateNumber(value, "20060102"); got != 20260728 { + t.Fatalf("ymd = %d", got) + } + if got := dateNumber(value, "200601"); got != 202607 { + t.Fatalf("ym = %d", got) + } +} + +func TestProtectWalletResponseMasksAccountsAndListDetails(t *testing.T) { + value := map[string]any{ + "alipay_id": "account@example.com", + "card_no_last4": "1234", + "card_owner": "张三", + "args": "sensitive", + "callback_msg": "sensitive", + } + protectWalletResponse(value, false) + if value["alipay_id"] == "account@example.com" { + t.Fatal("payment account was not masked") + } + if value["card_no_masked"] != "****1234" { + t.Fatalf("masked card = %#v", value["card_no_masked"]) + } + if _, exists := value["card_no_last4"]; exists { + t.Fatal("raw card last four remains in response") + } + if _, exists := value["args"]; exists { + t.Fatal("payment args remain in list response") + } + if _, exists := value["callback_msg"]; exists { + t.Fatal("callback message remains in list response") + } +} + +func TestProtectWalletResponseRetainsDetailOnlyFields(t *testing.T) { + value := map[string]any{"args": "detail", "callback_msg": "detail"} + protectWalletResponse(value, true) + if value["args"] != "detail" || value["callback_msg"] != "detail" { + t.Fatalf("detail fields were removed: %#v", value) + } +} diff --git a/backend/api/internal/models/wallet.go b/backend/api/internal/models/wallet.go deleted file mode 100644 index 3e61929..0000000 --- a/backend/api/internal/models/wallet.go +++ /dev/null @@ -1,15 +0,0 @@ -package models - -import "git.apinb.com/bsm-sdk/core/database" - -// Wallet 对应 wallet,保存余额账户。 -type Wallet struct { - Entity // 公共实体字段 - OwnerType string `gorm:"column:owner_type;type:varchar(32);not null" json:"owner_type"` // owner_type 业务字段 - OwnerID uint64 `gorm:"column:owner_id;not null;index" json:"owner_id"` // owner_id 业务字段 - BalanceAmount int64 `gorm:"column:balance_amount;not null;default:0" json:"balance_amount"` // balance_amount 业务字段 - FrozenAmount int64 `gorm:"column:frozen_amount;not null;default:0" json:"frozen_amount"` // frozen_amount 业务字段 -} - -func init() { database.AppendMigrate(&Wallet{}) } -func (table *Wallet) TableName() string { return "wallet" } diff --git a/backend/api/internal/models/wallet_apply_cash.go b/backend/api/internal/models/wallet_apply_cash.go new file mode 100644 index 0000000..e3471cd --- /dev/null +++ b/backend/api/internal/models/wallet_apply_cash.go @@ -0,0 +1,30 @@ +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// WalletApplyCash 对应 wallet_apply_cash,保存提现申请及审核结果。 +type WalletApplyCash struct { + Entity // 公共实体字段 + WalletBasicID uint64 `gorm:"column:wallet_basic_id;not null;index" json:"wallet_basic_id"` // 钱包自增主键 + WalletBankID uint64 `gorm:"column:wallet_bank_id;not null;default:0;index" json:"wallet_bank_id"` // 银行卡自增主键 + CashNo string `gorm:"column:cash_no;type:varchar(64);not null;uniqueIndex" json:"cash_no"` // 内部提现单号 + RequestNo string `gorm:"column:request_no;type:varchar(128);not null;uniqueIndex" json:"request_no"` // 申请幂等号 + Amount int64 `gorm:"column:amount;not null;check:amount > 0" json:"amount"` // 提现金额,单位分 + Fee int64 `gorm:"column:fee;not null;default:0;check:fee >= 0" json:"fee"` // 提现手续费,单位分 + Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"` // 提现渠道 + TradeNo string `gorm:"column:trade_no;type:varchar(128);not null;default:'';index" json:"trade_no"` // 第三方提现流水号 + Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 申请备注 + CallbackMsg string `gorm:"column:callback_msg;type:text;not null;default:''" json:"callback_msg"` // 提现回调信息 + ReviewerIdentity string `gorm:"column:reviewer_identity;type:varchar(36);not null;default:'';index" json:"reviewer_identity"` // 审核人业务标识 + ReviewerName string `gorm:"column:reviewer_name;type:varchar(64);not null;default:''" json:"reviewer_name"` // 审核人姓名快照 + ReviewedAt *time.Time `gorm:"column:reviewed_at;type:timestamptz" json:"reviewed_at"` // 审核时间 + ReviewReason string `gorm:"column:review_reason;type:text;not null;default:''" json:"review_reason"` // 审核原因 + CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // 完成时间 +} + +func init() { database.AppendMigrate(&WalletApplyCash{}) } +func (table *WalletApplyCash) TableName() string { return "wallet_apply_cash" } diff --git a/backend/api/internal/models/wallet_bank.go b/backend/api/internal/models/wallet_bank.go new file mode 100644 index 0000000..1ceb67a --- /dev/null +++ b/backend/api/internal/models/wallet_bank.go @@ -0,0 +1,22 @@ +package models + +import "git.apinb.com/bsm-sdk/core/database" + +// WalletBank 对应 wallet_bank,保存钱包绑定的银行卡密文档案。 +type WalletBank struct { + Entity // 公共实体字段 + WalletBasicID uint64 `gorm:"column:wallet_basic_id;not null;index;uniqueIndex:idx_wallet_card" json:"wallet_basic_id"` // 钱包自增主键 + CardNoCiphertext string `gorm:"column:card_no_ciphertext;type:text;not null" json:"-"` // 加密银行卡号 + CardFingerprint string `gorm:"column:card_fingerprint;type:varchar(64);not null;uniqueIndex:idx_wallet_card" json:"-"` // 银行卡号不可逆指纹 + CardNoLast4 string `gorm:"column:card_no_last4;type:varchar(4);not null;index" json:"card_no_last4"` // 银行卡号末四位 + BankName string `gorm:"column:bank_name;type:varchar(128);not null" json:"bank_name"` // 银行名称 + CardOwner string `gorm:"column:card_owner;type:varchar(128);not null" json:"card_owner"` // 持卡人姓名 + IDCardCiphertext string `gorm:"column:id_card_ciphertext;type:text;not null" json:"-"` // 加密身份证号 + PhoneCiphertext string `gorm:"column:phone_ciphertext;type:text;not null" json:"-"` // 加密银行预留手机号 + BindID string `gorm:"column:bind_id;type:varchar(128);not null;default:'';uniqueIndex:,where:bind_id <> ''" json:"bind_id"` // 支付渠道绑定标识 + BankType string `gorm:"column:bank_type;type:varchar(20);not null;default:''" json:"bank_type"` // 银行卡类型 + Bank string `gorm:"column:bank;type:varchar(64);not null;default:''" json:"bank"` // 所属银行编码 +} + +func init() { database.AppendMigrate(&WalletBank{}) } +func (table *WalletBank) TableName() string { return "wallet_bank" } diff --git a/backend/api/internal/models/wallet_basic.go b/backend/api/internal/models/wallet_basic.go new file mode 100644 index 0000000..592433a --- /dev/null +++ b/backend/api/internal/models/wallet_basic.go @@ -0,0 +1,21 @@ +package models + +import "git.apinb.com/bsm-sdk/core/database" + +// WalletBasic 对应 wallet_basic,保存多主体统一钱包账户。 +type WalletBasic struct { + Entity // 公共实体字段 + OwnerType string `gorm:"column:owner_type;type:varchar(32);not null;uniqueIndex:idx_wallet_owner" json:"owner_type"` // 归属主体类型 + OwnerID uint64 `gorm:"column:owner_id;not null;default:0;index" json:"owner_id"` // 归属主体自增主键 + OwnerIdentity string `gorm:"column:owner_identity;type:varchar(36);not null;uniqueIndex:idx_wallet_owner" json:"owner_identity"` // 归属主体业务标识 + AlipayID string `gorm:"column:alipay_id;type:varchar(128);not null;default:''" json:"alipay_id"` // 支付宝账号 + AlipayName string `gorm:"column:alipay_name;type:varchar(128);not null;default:''" json:"alipay_name"` // 支付宝账户名 + WxpayID string `gorm:"column:wxpay_id;type:varchar(128);not null;default:''" json:"wxpay_id"` // 微信支付账号 + WxpayName string `gorm:"column:wxpay_name;type:varchar(128);not null;default:''" json:"wxpay_name"` // 微信支付账户名 + PayPasswordHash string `gorm:"column:pay_password_hash;type:varchar(255);not null;default:''" json:"-"` // 支付密码哈希 + Balance int64 `gorm:"column:balance;not null;default:0;check:balance >= 0" json:"balance"` // 钱包余额,单位分 + WithdrawalBalance int64 `gorm:"column:withdrawal_balance;not null;default:0;check:withdrawal_balance >= 0" json:"withdrawal_balance"` // 可提现余额,单位分 +} + +func init() { database.AppendMigrate(&WalletBasic{}) } +func (table *WalletBasic) TableName() string { return "wallet_basic" } diff --git a/backend/api/internal/models/wallet_ledger.go b/backend/api/internal/models/wallet_ledger.go deleted file mode 100644 index 432c644..0000000 --- a/backend/api/internal/models/wallet_ledger.go +++ /dev/null @@ -1,16 +0,0 @@ -package models - -import "git.apinb.com/bsm-sdk/core/database" - -// WalletLedger 对应 wallet_ledger,保存不可变资金流水。 -type WalletLedger struct { - Entity // 公共实体字段 - WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"` // wallet_id 业务字段 - Amount int64 `gorm:"column:amount;not null" json:"amount"` // amount 业务字段 - Direction string `gorm:"column:direction;type:varchar(16);not null" json:"direction"` // direction 业务字段 - BalanceAfter int64 `gorm:"column:balance_after;not null" json:"balance_after"` // balance_after 业务字段 - ReferenceIdentity string `gorm:"column:reference_identity;type:varchar(36);not null;default:'';index" json:"reference_identity"` // reference_identity 业务字段 -} - -func init() { database.AppendMigrate(&WalletLedger{}) } -func (table *WalletLedger) TableName() string { return "wallet_ledger" } diff --git a/backend/api/internal/models/wallet_payment.go b/backend/api/internal/models/wallet_payment.go new file mode 100644 index 0000000..b7e3fad --- /dev/null +++ b/backend/api/internal/models/wallet_payment.go @@ -0,0 +1,22 @@ +package models + +import "git.apinb.com/bsm-sdk/core/database" + +// WalletPayment 对应 wallet_payment,保存第三方或余额支付单。 +type WalletPayment struct { + Entity // 公共实体字段 + WalletBasicID uint64 `gorm:"column:wallet_basic_id;not null;index" json:"wallet_basic_id"` // 钱包自增主键 + PaymentNo string `gorm:"column:payment_no;type:varchar(64);not null;uniqueIndex" json:"payment_no"` // 内部支付单号 + OrderNo string `gorm:"column:order_no;type:varchar(128);not null;index" json:"order_no"` // 业务订单号 + TradeNo string `gorm:"column:trade_no;type:varchar(128);not null;default:'';uniqueIndex:idx_wallet_payment_trade,where:trade_no <> ''" json:"trade_no"` // 第三方交易流水号 + PaymentType string `gorm:"column:payment_type;type:varchar(32);not null" json:"payment_type"` // 支付业务类型 + PayChannel string `gorm:"column:pay_channel;type:varchar(32);not null;uniqueIndex:idx_wallet_payment_trade,where:trade_no <> ''" json:"pay_channel"` // 支付渠道 + PayType string `gorm:"column:pay_type;type:varchar(64);not null;default:''" json:"pay_type"` // 渠道支付类型 + Amount int64 `gorm:"column:amount;not null;check:amount > 0" json:"amount"` // 支付金额,单位分 + Args string `gorm:"column:args;type:text;not null;default:''" json:"args"` // 支付参数 + Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 备注 + CallbackMsg string `gorm:"column:callback_msg;type:text;not null;default:''" json:"callback_msg"` // 支付回调信息 +} + +func init() { database.AppendMigrate(&WalletPayment{}) } +func (table *WalletPayment) TableName() string { return "wallet_payment" } diff --git a/backend/api/internal/models/wallet_recharge.go b/backend/api/internal/models/wallet_recharge.go deleted file mode 100644 index 0144b97..0000000 --- a/backend/api/internal/models/wallet_recharge.go +++ /dev/null @@ -1,14 +0,0 @@ -package models - -import "git.apinb.com/bsm-sdk/core/database" - -// WalletRecharge 对应 wallet_recharge,保存充值记录。 -type WalletRecharge struct { - Entity // 公共实体字段 - WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"` // wallet_id 业务字段 - Amount int64 `gorm:"column:amount;not null" json:"amount"` // amount 业务字段 - Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"` // channel 业务字段 -} - -func init() { database.AppendMigrate(&WalletRecharge{}) } -func (table *WalletRecharge) TableName() string { return "wallet_recharge" } diff --git a/backend/api/internal/models/wallet_record.go b/backend/api/internal/models/wallet_record.go new file mode 100644 index 0000000..968d305 --- /dev/null +++ b/backend/api/internal/models/wallet_record.go @@ -0,0 +1,30 @@ +package models + +import "git.apinb.com/bsm-sdk/core/database" + +// WalletRecord 对应 wallet_record,保存不可变资金流水。 +type WalletRecord struct { + Entity // 公共实体字段 + WalletBasicID uint64 `gorm:"column:wallet_basic_id;not null;index" json:"wallet_basic_id"` // 钱包自增主键 + RecordNo string `gorm:"column:record_no;type:varchar(64);not null;uniqueIndex" json:"record_no"` // 内部流水号 + RequestNo string `gorm:"column:request_no;type:varchar(128);not null;uniqueIndex" json:"request_no"` // 业务幂等号 + Direction string `gorm:"column:direction;type:varchar(16);not null" json:"direction"` // 收支方向 + TradeType string `gorm:"column:trade_type;type:varchar(32);not null;index" json:"trade_type"` // 交易类型 + Amount int64 `gorm:"column:amount;not null;check:amount > 0" json:"amount"` // 交易金额,单位分 + Fee int64 `gorm:"column:fee;not null;default:0;check:fee >= 0" json:"fee"` // 手续费,单位分 + BalanceAfter int64 `gorm:"column:balance_after;not null;check:balance_after >= 0" json:"balance_after"` // 交易后余额 + WithdrawalBalanceAfter int64 `gorm:"column:withdrawal_balance_after;not null;check:withdrawal_balance_after >= 0" json:"withdrawal_balance_after"` // 交易后可提现余额 + InTradeNo string `gorm:"column:in_trade_no;type:varchar(128);not null;default:'';index" json:"in_trade_no"` // 内部业务流水号 + OutTradeNo string `gorm:"column:out_trade_no;type:varchar(128);not null;default:'';index" json:"out_trade_no"` // 外部渠道流水号 + PayChannel string `gorm:"column:pay_channel;type:varchar(32);not null;default:''" json:"pay_channel"` // 支付渠道 + PayType string `gorm:"column:pay_type;type:varchar(64);not null;default:''" json:"pay_type"` // 渠道支付类型 + RelatedRecordID uint64 `gorm:"column:related_record_id;not null;default:0;index" json:"related_record_id"` // 冲正关联原流水自增主键 + OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:'';index" json:"operator_identity"` // 操作者业务标识 + OperatorName string `gorm:"column:operator_name;type:varchar(64);not null;default:''" json:"operator_name"` // 操作者姓名快照 + Ymd int32 `gorm:"column:ymd;not null;index" json:"ymd"` // 年月日索引 + Ym int32 `gorm:"column:ym;not null;index" json:"ym"` // 年月索引 + Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 交易备注 +} + +func init() { database.AppendMigrate(&WalletRecord{}) } +func (table *WalletRecord) TableName() string { return "wallet_record" } diff --git a/backend/api/internal/models/wallet_refund.go b/backend/api/internal/models/wallet_refund.go new file mode 100644 index 0000000..3cf8d22 --- /dev/null +++ b/backend/api/internal/models/wallet_refund.go @@ -0,0 +1,26 @@ +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// WalletRefund 对应 wallet_refund,保存支付退款结果。 +type WalletRefund struct { + Entity // 公共实体字段 + WalletBasicID uint64 `gorm:"column:wallet_basic_id;not null;index" json:"wallet_basic_id"` // 钱包自增主键 + WalletPaymentID uint64 `gorm:"column:wallet_payment_id;not null;index" json:"wallet_payment_id"` // 原支付记录自增主键 + RefundNo string `gorm:"column:refund_no;type:varchar(64);not null;uniqueIndex" json:"refund_no"` // 内部退款单号 + OrderIdentity string `gorm:"column:order_identity;type:varchar(64);not null;index" json:"order_identity"` // 订单业务标识 + Amount int64 `gorm:"column:amount;not null;check:amount > 0" json:"amount"` // 退款金额,单位分 + Fee int64 `gorm:"column:fee;not null;default:0;check:fee >= 0" json:"fee"` // 退款手续费,单位分 + Reason string `gorm:"column:reason;type:text;not null;default:''" json:"reason"` // 退款原因 + OrderInfo string `gorm:"column:order_info;type:text;not null;default:''" json:"order_info"` // 订单信息快照 + Result string `gorm:"column:result;type:text;not null;default:''" json:"result"` // 第三方处理结果 + TradeNo string `gorm:"column:trade_no;type:varchar(128);not null;default:'';index" json:"trade_no"` // 第三方退款流水号 + CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // 完成时间 +} + +func init() { database.AppendMigrate(&WalletRefund{}) } +func (table *WalletRefund) TableName() string { return "wallet_refund" } diff --git a/backend/api/internal/models/wallet_withdrawal.go b/backend/api/internal/models/wallet_withdrawal.go deleted file mode 100644 index 9337159..0000000 --- a/backend/api/internal/models/wallet_withdrawal.go +++ /dev/null @@ -1,14 +0,0 @@ -package models - -import "git.apinb.com/bsm-sdk/core/database" - -// WalletWithdrawal 对应 wallet_withdrawal,保存提现记录。 -type WalletWithdrawal struct { - Entity // 公共实体字段 - WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"` // wallet_id 业务字段 - Amount int64 `gorm:"column:amount;not null" json:"amount"` // amount 业务字段 - BankAccountMasked string `gorm:"column:bank_account_masked;type:varchar(128);not null;default:''" json:"bank_account_masked"` // bank_account_masked 业务字段 -} - -func init() { database.AppendMigrate(&WalletWithdrawal{}) } -func (table *WalletWithdrawal) TableName() string { return "wallet_withdrawal" } diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index 18608a5..d547ab8 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -28,6 +28,7 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) { registerStaffRoute(protected) registerUserRoute(protected) registerProductRoute(protected) + registerWalletRoute(protected) registerCommerceRoute(protected) registerFinanceRoute(protected) registerContentRoute(protected) @@ -87,6 +88,37 @@ func registerProductRoute(group *gin.RouterGroup) { owner.GET("/:identity", ownerGet) } +func registerWalletRoute(group *gin.RouterGroup) { + basic := group.Group("/wallet_basic") + basic.GET("", platform.ListWalletBasic) + basic.GET("/:identity", platform.GetWalletBasic) + basic.PATCH("/:identity/status", platform.UpdateWalletBasicStatus) + basic.POST("/:identity/recharge", platform.RechargeWalletBasic) + basic.GET("/owner/:owner_type/:owner_identity", platform.GetOrCreateOwnerWallet) + + bank := group.Group("/wallet_bank") + bank.GET("", platform.ListWalletBank) + bank.GET("/:identity", platform.GetWalletBank) + + payment := group.Group("/wallet_payment") + payment.GET("", platform.ListWalletPayment) + payment.GET("/:identity", platform.GetWalletPayment) + + record := group.Group("/wallet_record") + record.GET("", platform.ListWalletRecord) + record.GET("/:identity", platform.GetWalletRecord) + + refund := group.Group("/wallet_refund") + refund.GET("", platform.ListWalletRefund) + refund.GET("/:identity", platform.GetWalletRefund) + + applyCash := group.Group("/wallet_apply_cash") + applyCash.GET("", platform.ListWalletApplyCash) + applyCash.GET("/:identity", platform.GetWalletApplyCash) + applyCash.POST("/:identity/approve", platform.ApproveWalletApplyCash) + applyCash.POST("/:identity/reject", platform.RejectWalletApplyCash) +} + func registerCommerceRoute(group *gin.RouterGroup) { categoryRelations := []platform.ResourceRelation{optionalRelation("parent_identity", "parent_id", &models.EcCategory{})} _, categoryCreate, _, categoryUpdate := platform.ResourceHandlers(&models.EcCategory{}, []string{"name", "sort_no"}, []string{"name", "sort_no"}, categoryRelations...) @@ -140,10 +172,6 @@ func registerFinanceRoute(group *gin.RouterGroup) { registerWritableResource(group, "/finance/fin_settlement", settlementList, settlementCreate, settlementGet, settlementUpdate, &models.FinSettlement{}) registerRestrictedWritableResource(group, "/finance/fin_reconciliation", &models.FinReconciliation{}, []string{"channel", "bill_date", "difference_amount"}) - registerReadOnlyResource(group, "/wallet/wallet", &models.Wallet{}) - registerReadOnlyResource(group, "/wallet/wallet_ledger", &models.WalletLedger{}) - registerReadOnlyResource(group, "/wallet/wallet_recharge", &models.WalletRecharge{}) - registerReadOnlyResource(group, "/wallet/wallet_withdrawal", &models.WalletWithdrawal{}) } func registerContentRoute(group *gin.RouterGroup) { diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index f5c63d5..4317d33 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -166,7 +166,7 @@ func TestPlatformFinanceContentRoutesFollowTheirContracts(t *testing.T) { } for _, resource := range []string{ - "/wallet/wallet", "/wallet/wallet_ledger", "/wallet/wallet_recharge", "/wallet/wallet_withdrawal", + "/wallet_basic", "/wallet_bank", "/wallet_payment", "/wallet_record", "/wallet_refund", "/wallet_apply_cash", } { path := "/heqi/platform/v1" + resource assertRouteMethods(t, routes, path, http.MethodGet) @@ -177,6 +177,19 @@ func TestPlatformFinanceContentRoutesFollowTheirContracts(t *testing.T) { } } } + assertRouteMethods(t, routes, "/heqi/platform/v1/wallet_basic/:identity/status", http.MethodPatch) + assertRouteMethods(t, routes, "/heqi/platform/v1/wallet_basic/:identity/recharge", http.MethodPost) + assertRouteMethods(t, routes, "/heqi/platform/v1/wallet_basic/owner/:owner_type/:owner_identity", http.MethodGet) + assertRouteMethods(t, routes, "/heqi/platform/v1/wallet_apply_cash/:identity/approve", http.MethodPost) + assertRouteMethods(t, routes, "/heqi/platform/v1/wallet_apply_cash/:identity/reject", http.MethodPost) + for _, oldPath := range []string{ + "/heqi/platform/v1/wallet/wallet", + "/heqi/platform/v1/wallet/wallet_ledger", + "/heqi/platform/v1/wallet/wallet_recharge", + "/heqi/platform/v1/wallet/wallet_withdrawal", + } { + assertNoRouteMethods(t, routes, oldPath, http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete) + } for _, resource := range []string{ "/content/cnt_content", "/notification/ntf_template",