refactor wallet domain management

This commit is contained in:
david
2026-07-28 09:25:49 +08:00
parent 8d71a1fc08
commit 8c6d52cf8c
20 changed files with 606 additions and 68 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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{

View File

@@ -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)
}

View File

@@ -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)
}
}