feat: integrate unified payment and wallet refunds
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/payment"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -36,6 +37,36 @@ func ServiceRelation(ctx *gin.Context) {
|
||||
infra.Response.Success(ctx, response)
|
||||
}
|
||||
|
||||
// PayGasOrder 为本人未履约供气订单创建统一第三方支付单。
|
||||
func PayGasOrder(ctx *gin.Context) {
|
||||
account, ok := common.UserAccount(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
RequestNo string `json:"request_no" binding:"required"`
|
||||
Channel string `json:"channel" binding:"required,oneof=alipay wechat"`
|
||||
PayType string `json:"pay_type" binding:"required"`
|
||||
OpenID string `json:"openid"`
|
||||
}
|
||||
if ctx.ShouldBindJSON(&request) != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
var order models.GasorderBasic
|
||||
if err := impl.DBService.Where("identity = ? AND user_account_id = ? AND order_status IN ?", ctx.Param("identity"), account.ID, []int{common.StatusCreated, common.StatusAssigned}).First(&order).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
payOrder, err := payment.Create(ctx, payment.CreateInput{RequestNo: request.RequestNo, BusinessType: "gasorder", BusinessIdentity: order.Identity,
|
||||
UserIdentity: account.Identity, Channel: request.Channel, PayType: request.PayType, Subject: "和气供气订单 " + order.OrderNo, OpenID: request.OpenID, Amount: order.PayableAmount})
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, payment.PublicResponse(payOrder))
|
||||
}
|
||||
|
||||
// ListGasContracts 返回用户自己的供气合同。
|
||||
func ListGasContracts(ctx *gin.Context) {
|
||||
account, ok := common.UserAccount(ctx)
|
||||
@@ -47,7 +78,18 @@ func ListGasContracts(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, common.ResourceResponse(list))
|
||||
response := make([]gin.H, 0, len(list))
|
||||
for _, order := range list {
|
||||
var items []models.GasorderItem
|
||||
if err := impl.DBService.Where("gasorder_basic_id = ?", order.ID).Find(&items).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
value := common.ResourceResponse(order).(map[string]any)
|
||||
value["items"] = common.ResourceResponse(items)
|
||||
response = append(response, value)
|
||||
}
|
||||
infra.Response.Success(ctx, response)
|
||||
}
|
||||
|
||||
// ListGasOrders 返回用户自己的供气订单。
|
||||
|
||||
48
backend/api/internal/logic/client/user/refund.go
Normal file
48
backend/api/internal/logic/client/user/refund.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/payment"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func CreateRefund(businessType string) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
account, ok := common.UserAccount(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
RequestNo string `json:"request_no" binding:"required"`
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Items []payment.RefundItemInput `json:"items" binding:"required,min=1"`
|
||||
}
|
||||
if ctx.ShouldBindJSON(&request) != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
refund, err := payment.CreateRefund(account.ID, account.Identity, businessType, ctx.Param("identity"), payment.RefundInput{RequestNo: request.RequestNo, Reason: request.Reason, Description: request.Description, Items: request.Items})
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, common.ResourceResponse(refund))
|
||||
}
|
||||
}
|
||||
func ListRefunds(ctx *gin.Context) {
|
||||
account, ok := common.UserAccount(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var list []models.PaymentRefund
|
||||
if err := impl.DBService.Where("user_identity = ?", account.Identity).Order("created_at desc").Find(&list).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, common.ResourceResponse(list))
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/payment"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -21,7 +22,18 @@ func PublicProducts(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, common.ResourceResponse(list))
|
||||
response := make([]gin.H, 0, len(list))
|
||||
for _, order := range list {
|
||||
var items []models.EcOrderItem
|
||||
if err := impl.DBService.Where("ec_order_id = ?", order.ID).Find(&items).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
value := common.ResourceResponse(order).(map[string]any)
|
||||
value["items"] = common.ResourceResponse(items)
|
||||
response = append(response, value)
|
||||
}
|
||||
infra.Response.Success(ctx, response)
|
||||
}
|
||||
|
||||
// CreateShopOrder 按服务端价格创建订单并原子扣减库存。
|
||||
@@ -150,13 +162,31 @@ func PayShopOrder(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
PaymentPassword string `json:"payment_password" binding:"required"`
|
||||
PaymentPassword string `json:"payment_password"`
|
||||
RequestNo string `json:"request_no" binding:"required"`
|
||||
Channel string `json:"channel" binding:"required,oneof=wallet alipay wechat"`
|
||||
PayType string `json:"pay_type"`
|
||||
OpenID string `json:"openid"`
|
||||
}
|
||||
if ctx.ShouldBindJSON(&request) != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
if request.Channel != "wallet" {
|
||||
var order models.EcOrder
|
||||
if err := impl.DBService.Where("identity = ? AND user_account_id = ? AND order_status = ?", ctx.Param("identity"), account.ID, 16).First(&order).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
payOrder, err := payment.Create(ctx, payment.CreateInput{RequestNo: request.RequestNo, BusinessType: "ec_order", BusinessIdentity: order.Identity,
|
||||
UserIdentity: account.Identity, Channel: request.Channel, PayType: request.PayType, Subject: "和气商城订单 " + order.OrderNo, OpenID: request.OpenID, Amount: order.PayableAmount})
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, payment.PublicResponse(payOrder))
|
||||
return
|
||||
}
|
||||
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
var order models.EcOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"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/logic/payment"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -126,6 +127,8 @@ func CreateRecharge(client string) gin.HandlerFunc {
|
||||
Amount int64 `json:"amount" binding:"required,gt=0"`
|
||||
Channel string `json:"channel" binding:"required,oneof=mock wechat alipay"`
|
||||
RequestNo string `json:"request_no" binding:"required"`
|
||||
PayType string `json:"pay_type"`
|
||||
OpenID string `json:"openid"`
|
||||
}
|
||||
if ctx.ShouldBindJSON(&request) != nil || request.Amount > config.Spec.Global.ManualRechargeMaxAmount ||
|
||||
(request.Channel == "mock" && !config.Spec.Global.MockPaymentEnabled) {
|
||||
@@ -150,6 +153,16 @@ func CreateRecharge(client string) gin.HandlerFunc {
|
||||
}
|
||||
order = existing
|
||||
}
|
||||
if request.Channel != "mock" {
|
||||
payOrder, payErr := payment.Create(ctx, payment.CreateInput{RequestNo: request.RequestNo, BusinessType: "recharge", BusinessIdentity: order.Identity,
|
||||
UserIdentity: owner.Identity, Channel: request.Channel, PayType: request.PayType, Subject: "和气钱包充值 " + order.RechargeNo, OpenID: request.OpenID, Amount: order.Amount})
|
||||
if payErr != nil {
|
||||
infra.Response.Error(ctx, payErr)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, payment.PublicResponse(payOrder))
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, ResourceResponse(order))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package common
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -419,7 +419,7 @@ var relationIdentityModels = map[string]any{
|
||||
"gasorder_track_id": &models.GasorderTrack{},
|
||||
"platform_role_id": &models.PlatformRole{},
|
||||
"wallet_basic_id": &models.WalletBasic{},
|
||||
"wallet_payment_id": &models.WalletPayment{},
|
||||
"payment_order_id": &models.PaymentOrder{},
|
||||
"wallet_bank_id": &models.WalletBank{},
|
||||
"related_record_id": &models.WalletRecord{},
|
||||
}
|
||||
|
||||
@@ -56,9 +56,9 @@ func listWalletChild(ctx *gin.Context, model any, table string) {
|
||||
}
|
||||
|
||||
func ListBank(ctx *gin.Context) { listWalletChild(ctx, &models.WalletBank{}, "wallet_bank") }
|
||||
func ListPayment(ctx *gin.Context) { listWalletChild(ctx, &models.WalletPayment{}, "wallet_payment") }
|
||||
func ListPayment(ctx *gin.Context) { listWalletChild(ctx, &models.PaymentOrder{}, "payment_order") }
|
||||
func ListRecord(ctx *gin.Context) { listWalletChild(ctx, &models.WalletRecord{}, "wallet_record") }
|
||||
func ListRefund(ctx *gin.Context) { listWalletChild(ctx, &models.WalletRefund{}, "wallet_refund") }
|
||||
func ListRefund(ctx *gin.Context) { listWalletChild(ctx, &models.PaymentRefund{}, "payment_refund") }
|
||||
func ListRecharge(ctx *gin.Context) {
|
||||
point, _, ok := currentScope(ctx)
|
||||
if !ok {
|
||||
@@ -87,9 +87,9 @@ func getWalletChild(ctx *gin.Context, model any, table string) {
|
||||
}
|
||||
|
||||
func GetBank(ctx *gin.Context) { getWalletChild(ctx, &models.WalletBank{}, "wallet_bank") }
|
||||
func GetPayment(ctx *gin.Context) { getWalletChild(ctx, &models.WalletPayment{}, "wallet_payment") }
|
||||
func GetPayment(ctx *gin.Context) { getWalletChild(ctx, &models.PaymentOrder{}, "payment_order") }
|
||||
func GetRecord(ctx *gin.Context) { getWalletChild(ctx, &models.WalletRecord{}, "wallet_record") }
|
||||
func GetRefund(ctx *gin.Context) { getWalletChild(ctx, &models.WalletRefund{}, "wallet_refund") }
|
||||
func GetRefund(ctx *gin.Context) { getWalletChild(ctx, &models.PaymentRefund{}, "payment_refund") }
|
||||
func GetRecharge(ctx *gin.Context) {
|
||||
point, _, ok := currentScope(ctx)
|
||||
if !ok {
|
||||
|
||||
@@ -32,9 +32,9 @@ var adminMenus = []Menu{
|
||||
{Identity: "finance", GroupCode: "finance", Name: "财务管理", Icon: "icon-bar-chart", Path: "/finance", SortNo: 70, Status: common.StatusEnable},
|
||||
{Identity: "wallet_basic", ParentIdentity: "finance", GroupCode: "finance", Name: "钱包", Path: "/finance/wallet", SortNo: 1, Status: common.StatusEnable},
|
||||
{Identity: "wallet_bank", ParentIdentity: "finance", GroupCode: "finance", Name: "银行卡", Path: "/finance/banks", SortNo: 2, Status: common.StatusEnable},
|
||||
{Identity: "wallet_payment", ParentIdentity: "finance", GroupCode: "finance", Name: "支付记录", Path: "/finance/payments", SortNo: 3, Status: common.StatusEnable},
|
||||
{Identity: "payment_order", ParentIdentity: "finance", GroupCode: "finance", Name: "支付记录", Path: "/finance/payments", SortNo: 3, Status: common.StatusEnable},
|
||||
{Identity: "wallet_record", ParentIdentity: "finance", GroupCode: "finance", Name: "钱包流水", Path: "/finance/records", SortNo: 4, Status: common.StatusEnable},
|
||||
{Identity: "wallet_refund", ParentIdentity: "finance", GroupCode: "finance", Name: "退款记录", Path: "/finance/refunds", SortNo: 5, Status: common.StatusEnable},
|
||||
{Identity: "payment_refund", ParentIdentity: "finance", GroupCode: "finance", Name: "退款记录", Path: "/finance/refunds", SortNo: 5, Status: common.StatusEnable},
|
||||
{Identity: "wallet_recharge", ParentIdentity: "finance", GroupCode: "finance", Name: "钱包充值", Path: "/finance/recharge", SortNo: 6, Status: common.StatusEnable},
|
||||
{Identity: "wallet_apply_cash", ParentIdentity: "finance", GroupCode: "finance", Name: "提现申请", Path: "/finance/withdrawals", SortNo: 7, Status: common.StatusEnable},
|
||||
{Identity: "fin_settlement", ParentIdentity: "finance", GroupCode: "finance", Name: "结算结果", Path: "/finance/settlements", SortNo: 8, Status: common.StatusEnable},
|
||||
|
||||
@@ -371,8 +371,8 @@ func AdjustOrderAmount(ctx *gin.Context) {
|
||||
}
|
||||
var paymentCount int64
|
||||
if err := tx.Model(&models.GasorderPayment{}).
|
||||
Joins("JOIN wallet_payment ON wallet_payment.id = gasorder_payment.wallet_payment_id").
|
||||
Where("gasorder_payment.gasorder_basic_id = ? AND wallet_payment.payment_status = ?", order.ID, common.StatusPaid).
|
||||
Joins("JOIN payment_order ON payment_order.id = gasorder_payment.payment_order_id").
|
||||
Where("gasorder_payment.gasorder_basic_id = ? AND payment_order.payment_status = ?", order.ID, 23).
|
||||
Count(&paymentCount).Error; err != nil || paymentCount > 0 {
|
||||
return errors.New("paid order cannot be adjusted")
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@ func ExpectedResources() []ResourceContract {
|
||||
{"gasorder", "gasorder_basic", "/gasorder_basic", "list", "append_only"},
|
||||
{"finance", "wallet_basic", "/wallet_basic", "list", "readonly"},
|
||||
{"finance", "wallet_bank", "/wallet_bank", "list", "readonly"},
|
||||
{"finance", "wallet_payment", "/wallet_payment", "list", "readonly"},
|
||||
{"finance", "payment_order", "/payment_order", "list", "readonly"},
|
||||
{"finance", "wallet_record", "/wallet_record", "list", "readonly"},
|
||||
{"finance", "wallet_refund", "/wallet_refund", "list", "readonly"},
|
||||
{"finance", "payment_refund", "/payment_refund", "list", "readonly"},
|
||||
{"finance", "wallet_recharge", "/wallet_recharge", "list", "append_only"},
|
||||
{"finance", "wallet_apply_cash", "/wallet_apply_cash", "list", "append_only"},
|
||||
{"finance", "fin_settlement", "/fin_settlement", "list", "readonly"},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package gas
|
||||
package gas
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -45,14 +45,14 @@ func listWalletChild(ctx *gin.Context, model any, table string) {
|
||||
}
|
||||
|
||||
func ListWalletBank(ctx *gin.Context) { listWalletChild(ctx, &models.WalletBank{}, "wallet_bank") }
|
||||
func ListWalletPayment(ctx *gin.Context) {
|
||||
listWalletChild(ctx, &models.WalletPayment{}, "wallet_payment")
|
||||
func ListPaymentOrder(ctx *gin.Context) {
|
||||
listWalletChild(ctx, &models.PaymentOrder{}, "payment_order")
|
||||
}
|
||||
func ListWalletRecord(ctx *gin.Context) {
|
||||
listWalletChild(ctx, &models.WalletRecord{}, "wallet_record")
|
||||
}
|
||||
func ListWalletRefund(ctx *gin.Context) {
|
||||
listWalletChild(ctx, &models.WalletRefund{}, "wallet_refund")
|
||||
func ListPaymentRefund(ctx *gin.Context) {
|
||||
listWalletChild(ctx, &models.PaymentRefund{}, "payment_refund")
|
||||
}
|
||||
func ListWalletApplyCash(ctx *gin.Context) {
|
||||
listWalletChild(ctx, &models.WalletApplyCash{}, "wallet_apply_cash")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package gas
|
||||
package gas
|
||||
|
||||
import "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
|
||||
@@ -35,9 +35,9 @@ var adminMenus = []Menu{
|
||||
{Identity: "finance", GroupCode: "finance", Name: "财务管理", Icon: "icon-bar-chart", Path: "/finance", SortNo: 70, Status: common.StatusEnable},
|
||||
{Identity: "wallet_basic", ParentIdentity: "finance", GroupCode: "finance", Name: "钱包", Path: "/finance/wallet", SortNo: 1, Status: common.StatusEnable},
|
||||
{Identity: "wallet_bank", ParentIdentity: "finance", GroupCode: "finance", Name: "银行卡", Path: "/finance/banks", SortNo: 2, Status: common.StatusEnable},
|
||||
{Identity: "wallet_payment", ParentIdentity: "finance", GroupCode: "finance", Name: "支付记录", Path: "/finance/payments", SortNo: 3, Status: common.StatusEnable},
|
||||
{Identity: "payment_order", ParentIdentity: "finance", GroupCode: "finance", Name: "支付记录", Path: "/finance/payments", SortNo: 3, Status: common.StatusEnable},
|
||||
{Identity: "wallet_record", ParentIdentity: "finance", GroupCode: "finance", Name: "钱包流水", Path: "/finance/records", SortNo: 4, Status: common.StatusEnable},
|
||||
{Identity: "wallet_refund", ParentIdentity: "finance", GroupCode: "finance", Name: "退款记录", Path: "/finance/refunds", SortNo: 5, Status: common.StatusEnable},
|
||||
{Identity: "payment_refund", ParentIdentity: "finance", GroupCode: "finance", Name: "退款记录", Path: "/finance/refunds", SortNo: 5, Status: common.StatusEnable},
|
||||
{Identity: "wallet_apply_cash", ParentIdentity: "finance", GroupCode: "finance", Name: "提现申请", Path: "/finance/withdrawals", SortNo: 6, Status: common.StatusEnable},
|
||||
{Identity: "fin_settlement", ParentIdentity: "finance", GroupCode: "finance", Name: "财务结算", Path: "/finance/settlements", SortNo: 7, Status: common.StatusEnable},
|
||||
{Identity: "fin_reconciliation", ParentIdentity: "finance", GroupCode: "finance", Name: "财务对账", Path: "/finance/reconciliations", SortNo: 8, Status: common.StatusEnable},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package gas
|
||||
package gas
|
||||
|
||||
type ResourceMode string
|
||||
|
||||
@@ -30,8 +30,8 @@ func ExpectedResources() []ResourceContract {
|
||||
{"contract", "gasorder_contract_revision", ReadOnly}, {"gasorder", "gasorder_basic", AppendOnly},
|
||||
{"contract", "product_info", ReadOnly},
|
||||
{"finance", "wallet_basic", ReadOnly}, {"finance", "wallet_bank", ReadOnly},
|
||||
{"finance", "wallet_payment", ReadOnly}, {"finance", "wallet_record", ReadOnly},
|
||||
{"finance", "wallet_refund", ReadOnly}, {"finance", "wallet_apply_cash", AppendOnly},
|
||||
{"finance", "payment_order", ReadOnly}, {"finance", "wallet_record", ReadOnly},
|
||||
{"finance", "payment_refund", ReadOnly}, {"finance", "wallet_apply_cash", AppendOnly},
|
||||
{"finance", "fin_settlement", ReadOnly}, {"finance", "fin_reconciliation", ReadOnly},
|
||||
{"ticket", "cs_ticket", Writable},
|
||||
}
|
||||
|
||||
129
backend/api/internal/logic/payment/callback.go
Normal file
129
backend/api/internal/logic/payment/callback.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func digest(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// AlipayNotify 验证支付宝证书签名、商户身份、金额和状态后幂等入账。
|
||||
func AlipayNotify(ctx *gin.Context) {
|
||||
client, err := alipayClient()
|
||||
if err != nil {
|
||||
ctx.String(http.StatusServiceUnavailable, "failure")
|
||||
return
|
||||
}
|
||||
if err = ctx.Request.ParseForm(); err != nil || client.VerifySign(ctx, ctx.Request.PostForm) != nil {
|
||||
ctx.String(http.StatusBadRequest, "failure")
|
||||
return
|
||||
}
|
||||
values := ctx.Request.PostForm
|
||||
amount, amountErr := strconv.ParseFloat(values.Get("total_amount"), 64)
|
||||
if amountErr != nil || values.Get("app_id") != config.Spec.Payment.Alipay.AppID || (values.Get("trade_status") != "TRADE_SUCCESS" && values.Get("trade_status") != "TRADE_FINISHED") {
|
||||
ctx.String(http.StatusBadRequest, "failure")
|
||||
return
|
||||
}
|
||||
err = complete(values.Get("out_trade_no"), values.Get("trade_no"), int64(amount*100+0.5), "alipay", digest(values.Encode()))
|
||||
if err != nil {
|
||||
ctx.String(http.StatusConflict, "failure")
|
||||
return
|
||||
}
|
||||
ctx.String(http.StatusOK, "success")
|
||||
}
|
||||
|
||||
// WechatNotify 使用微信平台证书验签并解密 API v3 通知后幂等入账。
|
||||
func WechatNotify(ctx *gin.Context) {
|
||||
if _, err := wechatClient(ctx); err != nil {
|
||||
ctx.JSON(http.StatusServiceUnavailable, gin.H{"code": "FAIL", "message": "channel unavailable"})
|
||||
return
|
||||
}
|
||||
visitor := downloader.MgrInstance().GetCertificateVisitor(config.Spec.Payment.Wechat.MerchantID)
|
||||
handler := notify.NewNotifyHandler(config.Spec.Payment.Wechat.APIv3Key, verifiers.NewSHA256WithRSAVerifier(visitor))
|
||||
transaction := new(payments.Transaction)
|
||||
if _, err := handler.ParseNotifyRequest(ctx, ctx.Request, transaction); err != nil || transaction.OutTradeNo == nil || transaction.Amount == nil || transaction.Amount.Total == nil || transaction.TradeState == nil || *transaction.TradeState != "SUCCESS" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "invalid notification"})
|
||||
return
|
||||
}
|
||||
tradeNo := ""
|
||||
if transaction.TransactionId != nil {
|
||||
tradeNo = *transaction.TransactionId
|
||||
}
|
||||
if err := complete(*transaction.OutTradeNo, tradeNo, *transaction.Amount.Total, "wechat", digest(transaction.String())); err != nil {
|
||||
ctx.JSON(http.StatusConflict, gin.H{"code": "FAIL", "message": "payment conflict"})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "成功"})
|
||||
}
|
||||
|
||||
func complete(paymentNo, tradeNo string, amount int64, channel, callbackDigest string) error {
|
||||
return impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
var order models.PaymentOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("payment_no = ?", paymentNo).First(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if order.PaymentStatus == StatusPaid {
|
||||
return nil
|
||||
}
|
||||
if order.Channel != channel || order.Amount != amount {
|
||||
return errors.New("payment identity or amount mismatch")
|
||||
}
|
||||
if time.Now().After(order.ExpiresAt) {
|
||||
return tx.Model(&order).Updates(map[string]any{"payment_status": 50, "channel_trade_no": tradeNo, "callback_digest": callbackDigest, "failure_code": "PAID_AFTER_EXPIRED"}).Error
|
||||
}
|
||||
now := time.Now()
|
||||
if err := tx.Model(&order).Updates(map[string]any{"payment_status": StatusPaid, "channel_trade_no": tradeNo, "callback_digest": callbackDigest, "paid_at": &now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
switch order.BusinessType {
|
||||
case "ec_order":
|
||||
return tx.Model(&models.EcOrder{}).Where("identity = ? AND order_status = ?", order.BusinessIdentity, 16).Updates(map[string]any{"order_status": 18, "paid_at": &now}).Error
|
||||
case "gasorder":
|
||||
return tx.Model(&models.GasorderBasic{}).Where("identity = ? AND order_status IN ?", order.BusinessIdentity, []int{16, 18}).Update("order_status", 35).Error
|
||||
case "recharge":
|
||||
return completeRecharge(tx, order, now)
|
||||
}
|
||||
return errors.New("unsupported payment business")
|
||||
})
|
||||
}
|
||||
|
||||
func completeRecharge(tx *gorm.DB, payment models.PaymentOrder, now time.Time) error {
|
||||
var recharge models.WalletRechargeOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND recharge_status = ?", payment.BusinessIdentity, 10).First(&recharge).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var wallet models.WalletBasic
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&wallet, recharge.WalletBasicID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
wallet.Balance += payment.Amount
|
||||
if err := tx.Model(&wallet).Update("balance", wallet.Balance).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&recharge).Updates(map[string]any{"recharge_status": 23, "completed_at": &now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
date := now.In(time.Local)
|
||||
return tx.Create(&models.WalletRecord{Entity: models.Entity{Identity: models.NewIdentity(), Status: 1}, WalletBasicID: wallet.ID,
|
||||
RecordNo: "WR" + now.Format("20060102150405.000000"), RequestNo: "recharge:" + recharge.Identity, Direction: "income", TradeType: "recharge",
|
||||
Amount: payment.Amount, BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance, InTradeNo: recharge.RechargeNo,
|
||||
PayChannel: payment.Channel, OperatorIdentity: payment.UserIdentity, Ymd: int32(date.Year()*10000 + int(date.Month())*100 + date.Day()), Ym: int32(date.Year()*100 + int(date.Month()))}).Error
|
||||
}
|
||||
68
backend/api/internal/logic/payment/close.go
Normal file
68
backend/api/internal/logic/payment/close.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/smartwalle/alipay/v3"
|
||||
)
|
||||
|
||||
// CloseExpired 批量关闭已过期的渠道支付单;单笔失败留待下一轮安全重试。
|
||||
func CloseExpired(ctx context.Context, limit int) (int, error) {
|
||||
var orders []models.PaymentOrder
|
||||
if err := impl.DBService.Where("payment_status = ? AND expires_at <= ?", StatusPending, time.Now()).Order("expires_at asc").Limit(limit).Find(&orders).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
closed := 0
|
||||
for _, order := range orders {
|
||||
if err := closeChannelOrder(ctx, order); err != nil {
|
||||
continue
|
||||
}
|
||||
now := time.Now()
|
||||
result := impl.DBService.Model(&models.PaymentOrder{}).Where("id = ? AND payment_status = ?", order.ID, StatusPending).Updates(map[string]any{"payment_status": StatusClosed, "closed_at": &now})
|
||||
if result.Error == nil && result.RowsAffected == 1 {
|
||||
closed++
|
||||
}
|
||||
}
|
||||
return closed, nil
|
||||
}
|
||||
|
||||
func closeChannelOrder(ctx context.Context, order models.PaymentOrder) error {
|
||||
if order.Channel == "alipay" {
|
||||
client, err := alipayClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = client.TradeClose(ctx, alipay.TradeClose{OutTradeNo: order.PaymentNo})
|
||||
return err
|
||||
}
|
||||
if order.Channel == "wechat" {
|
||||
client, err := wechatClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = client.Post(ctx, fmt.Sprintf("https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/%s/close", order.PaymentNo), map[string]string{"mchid": config.Spec.Payment.Wechat.MerchantID})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseExpiredHandler 只接受 Worker 共享凭证,不暴露为平台用户动作。
|
||||
func CloseExpiredHandler(ctx *gin.Context) {
|
||||
if config.Spec.Payment.InternalServiceToken == "" || ctx.GetHeader("X-Heqi-Worker-Token") != config.Spec.Payment.InternalServiceToken {
|
||||
ctx.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
count, err := CloseExpired(ctx, 100)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "close expired payments failed"})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"closed": count})
|
||||
}
|
||||
139
backend/api/internal/logic/payment/provider.go
Normal file
139
backend/api/internal/logic/payment/provider.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Package payment 统一承载支付宝、微信和钱包支付请求及渠道回调。
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/smartwalle/alipay/v3"
|
||||
wechatcore "github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
|
||||
wechatapp "github.com/wechatpay-apiv3/wechatpay-go/services/payments/app"
|
||||
wechatjsapi "github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
|
||||
wechatnative "github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/utils"
|
||||
)
|
||||
|
||||
var ErrChannelUnavailable = errors.New("payment channel is not configured")
|
||||
|
||||
func money(amount int64) string { return fmt.Sprintf("%d.%02d", amount/100, amount%100) }
|
||||
|
||||
func createChannelOrder(ctx context.Context, order models.PaymentOrder, openID string) (string, error) {
|
||||
switch order.Channel {
|
||||
case "alipay":
|
||||
return createAlipayOrder(order)
|
||||
case "wechat":
|
||||
return createWechatOrder(ctx, order, openID)
|
||||
default:
|
||||
return "", errors.New("unsupported payment channel")
|
||||
}
|
||||
}
|
||||
|
||||
func alipayClient() (*alipay.Client, error) {
|
||||
cfg := config.Spec.Payment.Alipay
|
||||
if !cfg.Enabled || cfg.AppID == "" || cfg.PrivateKeyPath == "" {
|
||||
return nil, ErrChannelUnavailable
|
||||
}
|
||||
privateKey, err := os.ReadFile(cfg.PrivateKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := alipay.New(cfg.AppID, string(privateKey), cfg.Production)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = client.LoadAppCertPublicKeyFromFile(cfg.AppPublicCertPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = client.LoadAliPayRootCertFromFile(cfg.AlipayRootCertPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = client.LoadAlipayCertPublicKeyFromFile(cfg.AlipayPublicCertPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func createAlipayOrder(order models.PaymentOrder) (string, error) {
|
||||
client, err := alipayClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
trade := alipay.Trade{NotifyURL: config.Spec.Payment.Alipay.NotifyURL, ReturnURL: config.Spec.Payment.Alipay.ReturnURL,
|
||||
Subject: order.Subject, OutTradeNo: order.PaymentNo, TotalAmount: money(order.Amount), TimeoutExpress: fmt.Sprintf("%dm", config.Spec.Payment.ExpireMinutes)}
|
||||
if order.PayType == "app" {
|
||||
trade.ProductCode = "QUICK_MSECURITY_PAY"
|
||||
return client.TradeAppPay(alipay.TradeAppPay{Trade: trade})
|
||||
}
|
||||
if order.PayType == "wap" {
|
||||
trade.ProductCode = "QUICK_WAP_WAY"
|
||||
value, err := client.TradeWapPay(alipay.TradeWapPay{Trade: trade})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value.String(), nil
|
||||
}
|
||||
return "", errors.New("unsupported alipay product")
|
||||
}
|
||||
|
||||
func wechatClient(ctx context.Context) (*wechatcore.Client, error) {
|
||||
cfg := config.Spec.Payment.Wechat
|
||||
if !cfg.Enabled || cfg.MerchantID == "" || cfg.MerchantPrivateKeyPath == "" || len(cfg.APIv3Key) != 32 {
|
||||
return nil, ErrChannelUnavailable
|
||||
}
|
||||
key, err := utils.LoadPrivateKeyWithPath(cfg.MerchantPrivateKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wechatcore.NewClient(ctx, option.WithWechatPayAutoAuthCipher(cfg.MerchantID, cfg.MerchantCertificateSerial, key, cfg.APIv3Key))
|
||||
}
|
||||
|
||||
func createWechatOrder(ctx context.Context, order models.PaymentOrder, openID string) (string, error) {
|
||||
cfg := config.Spec.Payment.Wechat
|
||||
client, err := wechatClient(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
marshal := func(value any) (string, error) { raw, err := json.Marshal(value); return string(raw), err }
|
||||
switch order.PayType {
|
||||
case "app":
|
||||
resp, _, err := (&wechatapp.AppApiService{Client: client}).PrepayWithRequestPayment(ctx, wechatapp.PrepayRequest{
|
||||
Appid: wechatcore.String(cfg.AppAppID), Mchid: wechatcore.String(cfg.MerchantID), Description: wechatcore.String(order.Subject),
|
||||
OutTradeNo: wechatcore.String(order.PaymentNo), TimeExpire: &order.ExpiresAt, NotifyUrl: wechatcore.String(cfg.NotifyURL), Amount: &wechatapp.Amount{Total: wechatcore.Int64(order.Amount)}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return marshal(resp)
|
||||
case "jsapi", "mini":
|
||||
if strings.TrimSpace(openID) == "" {
|
||||
return "", errors.New("openid is required")
|
||||
}
|
||||
appid := cfg.OfficialAccountAppID
|
||||
if order.PayType == "mini" {
|
||||
appid = cfg.MiniProgramAppID
|
||||
}
|
||||
resp, _, err := (&wechatjsapi.JsapiApiService{Client: client}).PrepayWithRequestPayment(ctx, wechatjsapi.PrepayRequest{
|
||||
Appid: wechatcore.String(appid), Mchid: wechatcore.String(cfg.MerchantID), Description: wechatcore.String(order.Subject),
|
||||
OutTradeNo: wechatcore.String(order.PaymentNo), TimeExpire: &order.ExpiresAt, NotifyUrl: wechatcore.String(cfg.NotifyURL),
|
||||
Amount: &wechatjsapi.Amount{Total: wechatcore.Int64(order.Amount)}, Payer: &wechatjsapi.Payer{Openid: wechatcore.String(openID)}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return marshal(resp)
|
||||
case "native":
|
||||
resp, _, err := (&wechatnative.NativeApiService{Client: client}).Prepay(ctx, wechatnative.PrepayRequest{
|
||||
Appid: wechatcore.String(cfg.AppAppID), Mchid: wechatcore.String(cfg.MerchantID), Description: wechatcore.String(order.Subject),
|
||||
OutTradeNo: wechatcore.String(order.PaymentNo), TimeExpire: &order.ExpiresAt, NotifyUrl: wechatcore.String(cfg.NotifyURL), Amount: &wechatnative.Amount{Total: wechatcore.Int64(order.Amount)}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return marshal(resp)
|
||||
}
|
||||
return "", errors.New("unsupported wechat product")
|
||||
}
|
||||
166
backend/api/internal/logic/payment/refund.go
Normal file
166
backend/api/internal/logic/payment/refund.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type RefundItemInput struct {
|
||||
Identity string `json:"identity"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
type RefundInput struct {
|
||||
RequestNo, Reason, Description string
|
||||
Items []RefundItemInput
|
||||
}
|
||||
|
||||
// CreateRefund 校验本人订单、退款窗口、履约状态、数量和累计金额后创建待审核退款。
|
||||
func CreateRefund(userID uint64, userIdentity, businessType, businessIdentity string, input RefundInput) (models.PaymentRefund, error) {
|
||||
var result models.PaymentRefund
|
||||
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
var pay models.PaymentOrder
|
||||
if err := tx.Where("business_type = ? AND business_identity = ? AND user_identity = ? AND payment_status = ?", businessType, businessIdentity, userIdentity, StatusPaid).Order("paid_at desc").First(&pay).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if pay.PaidAt == nil || time.Now().After(pay.PaidAt.AddDate(0, 0, config.Spec.Payment.RefundWindowDays)) {
|
||||
return errors.New("refund window expired")
|
||||
}
|
||||
var wallet models.WalletBasic
|
||||
if err := tx.Where("owner_type = ? AND owner_identity = ? AND owner_id = ?", "user", userIdentity, userID).First(&wallet).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
items, whole, err := refundItems(tx, businessType, businessIdentity, userID, input.Items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var used int64
|
||||
if err := tx.Model(&models.PaymentRefund{}).Where("payment_order_id = ? AND refund_status IN ?", pay.ID, []int{10, 20}).Select("COALESCE(SUM(amount),0)").Scan(&used).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var amount int64
|
||||
for _, item := range items {
|
||||
amount += item.Amount
|
||||
}
|
||||
if whole {
|
||||
amount = pay.Amount - used
|
||||
}
|
||||
if amount <= 0 || used+amount > pay.Amount {
|
||||
return errors.New("refund amount exceeds payment")
|
||||
}
|
||||
result = models.PaymentRefund{Entity: models.Entity{Identity: models.NewIdentity(), Status: 1}, RefundStatus: 10, PaymentOrderID: pay.ID, WalletBasicID: wallet.ID,
|
||||
RefundNo: "RF" + time.Now().Format("20060102150405.000000"), RequestNo: input.RequestNo, BusinessType: businessType, BusinessIdentity: businessIdentity,
|
||||
UserIdentity: userIdentity, Amount: amount, Reason: input.Reason, Description: input.Description}
|
||||
if err := tx.Create(&result).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for index := range items {
|
||||
items[index].PaymentRefundID = result.ID
|
||||
items[index].Identity = models.NewIdentity()
|
||||
if err := tx.Create(&items[index]).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func refundItems(tx *gorm.DB, businessType, identity string, userID uint64, requested []RefundItemInput) ([]models.PaymentRefundItem, bool, error) {
|
||||
if len(requested) == 0 {
|
||||
return nil, false, errors.New("refund items are required")
|
||||
}
|
||||
result := make([]models.PaymentRefundItem, 0, len(requested))
|
||||
whole := true
|
||||
switch businessType {
|
||||
case "ec_order":
|
||||
var order models.EcOrder
|
||||
if err := tx.Where("identity = ? AND user_account_id = ? AND order_status = ? AND logistics_status < ?", identity, userID, 18, 30).First(&order).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var all []models.EcOrderItem
|
||||
if err := tx.Where("ec_order_id = ?", order.ID).Find(&all).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
by := map[string]models.EcOrderItem{}
|
||||
for _, value := range all {
|
||||
by[value.Identity] = value
|
||||
}
|
||||
for _, request := range requested {
|
||||
value, ok := by[request.Identity]
|
||||
if !ok || request.Quantity <= 0 || request.Quantity > value.Quantity {
|
||||
return nil, false, gorm.ErrInvalidData
|
||||
}
|
||||
if request.Quantity != value.Quantity {
|
||||
whole = false
|
||||
}
|
||||
result = append(result, models.PaymentRefundItem{OrderItemIdentity: value.Identity, Quantity: request.Quantity, Amount: value.SaleAmount * int64(request.Quantity)})
|
||||
}
|
||||
if len(requested) != len(all) {
|
||||
whole = false
|
||||
}
|
||||
case "gasorder":
|
||||
var order models.GasorderBasic
|
||||
if err := tx.Where("identity = ? AND user_account_id = ? AND order_status = ?", identity, userID, 35).First(&order).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var all []models.GasorderItem
|
||||
if err := tx.Where("gasorder_basic_id = ?", order.ID).Find(&all).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
by := map[string]models.GasorderItem{}
|
||||
for _, value := range all {
|
||||
by[value.Identity] = value
|
||||
}
|
||||
for _, request := range requested {
|
||||
value, ok := by[request.Identity]
|
||||
if !ok || request.Quantity != 1 {
|
||||
return nil, false, gorm.ErrInvalidData
|
||||
}
|
||||
result = append(result, models.PaymentRefundItem{OrderItemIdentity: value.Identity, Quantity: 1, Amount: value.UnitPrice})
|
||||
}
|
||||
if len(requested) != len(all) {
|
||||
whole = false
|
||||
}
|
||||
default:
|
||||
return nil, false, errors.New("business does not support refund")
|
||||
}
|
||||
return result, whole, nil
|
||||
}
|
||||
|
||||
// ReviewRefund 驳回时记录原因;通过时在同一事务中立即增加钱包与不可变流水。
|
||||
func ReviewRefund(identity, reviewer, remark string, approve bool) error {
|
||||
return impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
var refund models.PaymentRefund
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND refund_status = ?", identity, 10).First(&refund).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
if !approve {
|
||||
if remark == "" {
|
||||
return errors.New("reject reason required")
|
||||
}
|
||||
return tx.Model(&refund).Updates(map[string]any{"refund_status": 30, "review_remark": remark, "reviewer_identity": reviewer, "reviewed_at": &now}).Error
|
||||
}
|
||||
var wallet models.WalletBasic
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&wallet, refund.WalletBasicID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
wallet.Balance += refund.Amount
|
||||
if err := tx.Model(&wallet).Update("balance", wallet.Balance).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&refund).Updates(map[string]any{"refund_status": 20, "review_remark": remark, "reviewer_identity": reviewer, "reviewed_at": &now, "completed_at": &now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
date := now.In(time.Local)
|
||||
return tx.Create(&models.WalletRecord{Entity: models.Entity{Identity: models.NewIdentity(), Status: 1}, WalletBasicID: wallet.ID, RecordNo: models.NewIdentity(), RequestNo: "refund:" + refund.Identity,
|
||||
Direction: "income", TradeType: "refund", Amount: refund.Amount, BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance, InTradeNo: refund.RefundNo, PayChannel: "wallet", OperatorIdentity: reviewer,
|
||||
Ymd: int32(date.Year()*10000 + int(date.Month())*100 + date.Day()), Ym: int32(date.Year()*100 + int(date.Month())), Remark: "退款审核:" + remark}).Error
|
||||
})
|
||||
}
|
||||
61
backend/api/internal/logic/payment/service.go
Normal file
61
backend/api/internal/logic/payment/service.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusPending = 10
|
||||
StatusPaid = 23
|
||||
StatusClosed = 30
|
||||
)
|
||||
|
||||
type CreateInput struct {
|
||||
RequestNo, BusinessType, BusinessIdentity, UserIdentity string
|
||||
Channel, PayType, Subject, OpenID string
|
||||
Amount int64
|
||||
}
|
||||
|
||||
// Create 创建幂等支付单并向渠道请求客户端调起参数。
|
||||
func Create(ctx context.Context, input CreateInput) (models.PaymentOrder, error) {
|
||||
if input.Amount <= 0 || input.RequestNo == "" || input.BusinessIdentity == "" || input.UserIdentity == "" {
|
||||
return models.PaymentOrder{}, gorm.ErrInvalidData
|
||||
}
|
||||
var existing models.PaymentOrder
|
||||
if err := impl.DBService.Where("business_type = ? AND request_no = ?", input.BusinessType, input.RequestNo).First(&existing).Error; err == nil {
|
||||
if existing.BusinessIdentity != input.BusinessIdentity || existing.Amount != input.Amount || existing.Channel != input.Channel || existing.PayType != input.PayType {
|
||||
return existing, errors.New("idempotency conflict")
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
order := models.PaymentOrder{Entity: models.Entity{Identity: models.NewIdentity(), Status: 1}, PaymentStatus: StatusPending,
|
||||
PaymentNo: "PAY" + time.Now().Format("20060102150405.000000"), RequestNo: input.RequestNo, BusinessType: input.BusinessType,
|
||||
BusinessIdentity: input.BusinessIdentity, UserIdentity: input.UserIdentity, MerchantIdentity: "platform", Channel: input.Channel,
|
||||
PayType: input.PayType, Amount: input.Amount, Subject: input.Subject, ExpiresAt: time.Now().Add(time.Duration(config.Spec.Payment.ExpireMinutes) * time.Minute)}
|
||||
args, err := createChannelOrder(ctx, order, input.OpenID)
|
||||
if err != nil {
|
||||
return order, err
|
||||
}
|
||||
order.ClientArgs = args
|
||||
if err = impl.DBService.Create(&order).Error; err != nil {
|
||||
return order, err
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// PublicResponse 仅返回客户端调起支付所需的非密钥参数。
|
||||
func PublicResponse(order models.PaymentOrder) map[string]any {
|
||||
response := map[string]any{"identity": order.Identity, "payment_no": order.PaymentNo, "payment_status": order.PaymentStatus,
|
||||
"channel": order.Channel, "pay_type": order.PayType, "amount": order.Amount, "client_args": order.ClientArgs, "expires_at": order.ExpiresAt}
|
||||
if order.Channel == "wechat" && order.PayType == "app" {
|
||||
response["app_id"] = config.Spec.Payment.Wechat.AppAppID
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -103,9 +103,9 @@ func GetDashboardOverview() (DashboardStatistics, error) {
|
||||
Scan(&result.TodayOrderAmount).Error; err != nil {
|
||||
return DashboardStatistics{}, err
|
||||
}
|
||||
if err := impl.DBService.Model(&models.WalletPayment{}).
|
||||
if err := impl.DBService.Model(&models.PaymentOrder{}).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Where("status = ? AND payment_status = ?", common.StatusEnable, common.StatusPaid).
|
||||
Where("status = ? AND payment_status = ?", common.StatusEnable, 23).
|
||||
Scan(&result.PaidAmount).Error; err != nil {
|
||||
return DashboardStatistics{}, err
|
||||
}
|
||||
@@ -126,9 +126,9 @@ func GetDashboardOverview() (DashboardStatistics, error) {
|
||||
}
|
||||
result.ProductStatuses = namedStatuses(productStatuses, productStatusNames)
|
||||
|
||||
if err := impl.DBService.Model(&models.WalletPayment{}).
|
||||
if err := impl.DBService.Model(&models.PaymentOrder{}).
|
||||
Select("pay_channel AS name, COALESCE(SUM(amount), 0) AS value").
|
||||
Where("status = ? AND payment_status = ?", common.StatusEnable, common.StatusPaid).
|
||||
Where("status = ? AND payment_status = ?", common.StatusEnable, 23).
|
||||
Group("pay_channel").Order("value DESC").Scan(&result.PaymentChannels).Error; err != nil {
|
||||
return DashboardStatistics{}, err
|
||||
}
|
||||
|
||||
@@ -68,8 +68,9 @@ var PlatformMenus = [][]Menu{
|
||||
{Identity: "finance", GroupCode: "finance", Name: "财务管理", Icon: "icon-bar-chart", Path: "/finance", SortNo: 100, Status: common.StatusEnable},
|
||||
{Identity: "wallet_apply_cash", ParentIdentity: "finance", GroupCode: "finance", Name: "提现记录", Path: "/finance/withdrawals", SortNo: 1, Status: common.StatusEnable},
|
||||
{Identity: "fin_payment", ParentIdentity: "finance", GroupCode: "finance", Name: "支付记录", Path: "/finance/payments", SortNo: 2, Status: common.StatusEnable},
|
||||
{Identity: "fin_settlement", ParentIdentity: "finance", GroupCode: "finance", Name: "财务结算", Path: "/finance/settlements", SortNo: 3, Status: common.StatusEnable},
|
||||
{Identity: "fin_reconciliation", ParentIdentity: "finance", GroupCode: "finance", Name: "财务对账", Path: "/finance/reconciliations", SortNo: 4, Status: common.StatusEnable},
|
||||
{Identity: "payment_refund", ParentIdentity: "finance", GroupCode: "finance", Name: "退款审核", Path: "/finance/refunds", SortNo: 3, Status: common.StatusEnable},
|
||||
{Identity: "fin_settlement", ParentIdentity: "finance", GroupCode: "finance", Name: "财务结算", Path: "/finance/settlements", SortNo: 4, Status: common.StatusEnable},
|
||||
{Identity: "fin_reconciliation", ParentIdentity: "finance", GroupCode: "finance", Name: "财务对账", Path: "/finance/reconciliations", SortNo: 5, Status: common.StatusEnable},
|
||||
},
|
||||
{
|
||||
{Identity: "content", GroupCode: "content", Name: "内容管理", Icon: "icon-file", Path: "/content", SortNo: 110, Status: common.StatusEnable},
|
||||
|
||||
33
backend/api/internal/logic/platform/payment/refund.go
Normal file
33
backend/api/internal/logic/platform/payment/refund.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
paylogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/payment"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ListRefund(ctx *gin.Context) { common.ListResource(ctx, &models.PaymentRefund{}) }
|
||||
func GetRefund(ctx *gin.Context) { common.GetResource(ctx, &models.PaymentRefund{}) }
|
||||
func review(approve bool) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
var request struct {
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if ctx.ShouldBindJSON(&request) != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
operator, _ := common.PlatformOperator(ctx)
|
||||
if err := paylogic.ReviewRefund(ctx.Param("identity"), operator, request.Remark, approve); err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"reviewed": true})
|
||||
}
|
||||
}
|
||||
|
||||
var ApproveRefund = review(true)
|
||||
var RejectRefund = review(false)
|
||||
@@ -1,4 +1,4 @@
|
||||
package platform
|
||||
package platform
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -89,7 +89,7 @@ func ExpectedResources() []ResourceContract {
|
||||
resourceContract("finance", "fin_payment", ReadOnly, "list"), resourceContract("finance", "fin_settlement", Writable, "list"), resourceContract("finance", "fin_reconciliation", ReadOnly, "list"),
|
||||
resourceContract("content", "cms_content", Writable, "list"), resourceContract("customer_service", "cs_ticket", Writable, "list"),
|
||||
resourceContract("platform", "platform_account", Writable, "list"), resourceContract("platform", "platform_role", Writable, "list"), resourceContract("platform", "platform_menu", ReadOnly, "tree"),
|
||||
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"),
|
||||
resourceContract("wallet", "wallet_basic", ReadOnly, "list"), resourceContract("wallet", "wallet_bank", ReadOnly, "list"), resourceContract("wallet", "payment_order", ReadOnly, "list"), resourceContract("wallet", "wallet_record", ReadOnly, "list"), resourceContract("wallet", "payment_refund", ReadOnly, "list"), resourceContract("wallet", "wallet_apply_cash", ReadOnly, "list"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package wallet
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -44,12 +44,12 @@ func ListWalletBasic(ctx *gin.Context) {
|
||||
func GetWalletBasic(ctx *gin.Context) { getWalletByIdentity[models.WalletBasic](ctx) }
|
||||
func ListWalletBank(ctx *gin.Context) { listWalletPage[models.WalletBank](ctx, "", nil) }
|
||||
func GetWalletBank(ctx *gin.Context) { getWalletByIdentity[models.WalletBank](ctx) }
|
||||
func ListWalletPayment(ctx *gin.Context) { listWalletPage[models.WalletPayment](ctx, "", nil) }
|
||||
func GetWalletPayment(ctx *gin.Context) { getWalletByIdentity[models.WalletPayment](ctx) }
|
||||
func ListPaymentOrder(ctx *gin.Context) { listWalletPage[models.PaymentOrder](ctx, "", nil) }
|
||||
func GetPaymentOrder(ctx *gin.Context) { getWalletByIdentity[models.PaymentOrder](ctx) }
|
||||
func ListWalletRecord(ctx *gin.Context) { listWalletPage[models.WalletRecord](ctx, "", nil) }
|
||||
func GetWalletRecord(ctx *gin.Context) { getWalletByIdentity[models.WalletRecord](ctx) }
|
||||
func ListWalletRefund(ctx *gin.Context) { listWalletPage[models.WalletRefund](ctx, "", nil) }
|
||||
func GetWalletRefund(ctx *gin.Context) { getWalletByIdentity[models.WalletRefund](ctx) }
|
||||
func ListPaymentRefund(ctx *gin.Context) { listWalletPage[models.PaymentRefund](ctx, "", nil) }
|
||||
func GetPaymentRefund(ctx *gin.Context) { getWalletByIdentity[models.PaymentRefund](ctx) }
|
||||
func ListWalletApplyCash(ctx *gin.Context) { listWalletPage[models.WalletApplyCash](ctx, "", nil) }
|
||||
func GetWalletApplyCash(ctx *gin.Context) { getWalletByIdentity[models.WalletApplyCash](ctx) }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user