880 lines
27 KiB
Go
880 lines
27 KiB
Go
package app
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"math/rand"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
|
||
rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge"
|
||
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
|
||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||
"github.com/break/junhong_cmp_fiber/pkg/alipay"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
|
||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||
"github.com/break/junhong_cmp_fiber/pkg/wechat"
|
||
"github.com/gofiber/fiber/v2"
|
||
"github.com/redis/go-redis/v9"
|
||
"go.uber.org/zap"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// ClientWalletHandler C 端钱包处理器
|
||
// 提供 C1~C5 钱包详情、流水、充值前校验、充值下单、充值记录接口
|
||
type ClientWalletHandler struct {
|
||
assetService *asset.Service
|
||
customerBinding *customerBinding.Service
|
||
walletStore *postgres.AssetWalletStore
|
||
transactionStore *postgres.AssetWalletTransactionStore
|
||
rechargeOrderStore *postgres.RechargeOrderStore
|
||
paymentStore *postgres.PaymentStore
|
||
rechargeService *rechargeSvc.Service
|
||
openIDStore *postgres.PersonalCustomerOpenIDStore
|
||
wechatConfigService *wechatConfigSvc.Service
|
||
redis *redis.Client
|
||
logger *zap.Logger
|
||
db *gorm.DB
|
||
iotCardStore *postgres.IotCardStore
|
||
deviceStore *postgres.DeviceStore
|
||
}
|
||
|
||
// NewClientWalletHandler 创建 C 端钱包处理器
|
||
func NewClientWalletHandler(
|
||
assetService *asset.Service,
|
||
binding *customerBinding.Service,
|
||
walletStore *postgres.AssetWalletStore,
|
||
transactionStore *postgres.AssetWalletTransactionStore,
|
||
rechargeOrderStore *postgres.RechargeOrderStore,
|
||
paymentStore *postgres.PaymentStore,
|
||
rechargeService *rechargeSvc.Service,
|
||
openIDStore *postgres.PersonalCustomerOpenIDStore,
|
||
wechatConfigService *wechatConfigSvc.Service,
|
||
redisClient *redis.Client,
|
||
logger *zap.Logger,
|
||
db *gorm.DB,
|
||
iotCardStore *postgres.IotCardStore,
|
||
deviceStore *postgres.DeviceStore,
|
||
) *ClientWalletHandler {
|
||
return &ClientWalletHandler{
|
||
assetService: assetService,
|
||
customerBinding: binding,
|
||
walletStore: walletStore,
|
||
transactionStore: transactionStore,
|
||
rechargeOrderStore: rechargeOrderStore,
|
||
paymentStore: paymentStore,
|
||
rechargeService: rechargeService,
|
||
openIDStore: openIDStore,
|
||
wechatConfigService: wechatConfigService,
|
||
redis: redisClient,
|
||
logger: logger,
|
||
db: db,
|
||
iotCardStore: iotCardStore,
|
||
deviceStore: deviceStore,
|
||
}
|
||
}
|
||
|
||
type resolvedWalletAssetContext struct {
|
||
CustomerID uint
|
||
Identifier string
|
||
Asset *dto.AssetResolveResponse
|
||
Generation int
|
||
ResourceType string
|
||
SkipPermissionCtx context.Context
|
||
}
|
||
|
||
// GetWalletDetail C1 钱包详情
|
||
// GET /api/c/v1/wallet/detail
|
||
func (h *ClientWalletHandler) GetWalletDetail(c *fiber.Ctx) error {
|
||
var req dto.WalletDetailRequest
|
||
if err := c.QueryParser(&req); err != nil {
|
||
return errors.New(errors.CodeInvalidParam)
|
||
}
|
||
|
||
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
wallet, err := h.getOrCreateWallet(resolved)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
resp := &dto.WalletDetailResponse{
|
||
WalletID: wallet.ID,
|
||
ResourceType: wallet.ResourceType,
|
||
ResourceID: wallet.ResourceID,
|
||
Balance: wallet.Balance,
|
||
FrozenBalance: wallet.FrozenBalance,
|
||
UpdatedAt: wallet.UpdatedAt.Format(time.RFC3339),
|
||
}
|
||
|
||
return response.Success(c, resp)
|
||
}
|
||
|
||
// GetWalletTransactions C2 钱包流水列表
|
||
// GET /api/c/v1/wallet/transactions
|
||
func (h *ClientWalletHandler) GetWalletTransactions(c *fiber.Ctx) error {
|
||
var req dto.WalletTransactionListRequest
|
||
if err := c.QueryParser(&req); err != nil {
|
||
return errors.New(errors.CodeInvalidParam)
|
||
}
|
||
|
||
if req.Page < 1 {
|
||
req.Page = 1
|
||
}
|
||
if req.PageSize < 1 {
|
||
req.PageSize = constants.DefaultPageSize
|
||
}
|
||
if req.PageSize > constants.MaxPageSize {
|
||
req.PageSize = constants.MaxPageSize
|
||
}
|
||
|
||
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
wallet, err := h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return response.SuccessWithPagination(c, []dto.WalletTransactionItem{}, 0, req.Page, req.PageSize)
|
||
}
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
|
||
}
|
||
|
||
var txType *string
|
||
if strings.TrimSpace(req.TransactionType) != "" {
|
||
v := strings.TrimSpace(req.TransactionType)
|
||
txType = &v
|
||
}
|
||
|
||
startTime, err := parseOptionalTime(req.StartTime)
|
||
if err != nil {
|
||
return errors.New(errors.CodeInvalidParam)
|
||
}
|
||
endTime, err := parseOptionalTime(req.EndTime)
|
||
if err != nil {
|
||
return errors.New(errors.CodeInvalidParam)
|
||
}
|
||
if startTime != nil && endTime != nil && endTime.Before(*startTime) {
|
||
return errors.New(errors.CodeInvalidParam)
|
||
}
|
||
|
||
offset := (req.Page - 1) * req.PageSize
|
||
list, err := h.transactionStore.ListByResourceIDWithFilter(
|
||
resolved.SkipPermissionCtx,
|
||
wallet.ResourceType,
|
||
wallet.ResourceID,
|
||
txType,
|
||
startTime,
|
||
endTime,
|
||
offset,
|
||
req.PageSize,
|
||
)
|
||
if err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包流水失败")
|
||
}
|
||
|
||
total, err := h.transactionStore.CountByResourceIDWithFilter(
|
||
resolved.SkipPermissionCtx,
|
||
wallet.ResourceType,
|
||
wallet.ResourceID,
|
||
txType,
|
||
startTime,
|
||
endTime,
|
||
)
|
||
if err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包流水总数失败")
|
||
}
|
||
|
||
items := make([]dto.WalletTransactionItem, 0, len(list))
|
||
for _, tx := range list {
|
||
if tx == nil {
|
||
continue
|
||
}
|
||
|
||
remark := ""
|
||
if tx.Remark != nil {
|
||
remark = *tx.Remark
|
||
}
|
||
|
||
items = append(items, dto.WalletTransactionItem{
|
||
TransactionID: tx.ID,
|
||
Type: tx.TransactionType,
|
||
Amount: tx.Amount,
|
||
BalanceAfter: tx.BalanceAfter,
|
||
CreatedAt: tx.CreatedAt.Format(time.RFC3339),
|
||
Remark: remark,
|
||
})
|
||
}
|
||
|
||
return response.SuccessWithPagination(c, items, total, req.Page, req.PageSize)
|
||
}
|
||
|
||
// GetRechargeCheck C3 充值前校验
|
||
// GET /api/c/v1/wallet/recharge-check
|
||
func (h *ClientWalletHandler) GetRechargeCheck(c *fiber.Ctx) error {
|
||
var req dto.ClientRechargeCheckRequest
|
||
if err := c.QueryParser(&req); err != nil {
|
||
return errors.New(errors.CodeInvalidParam)
|
||
}
|
||
|
||
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// before_order 策略:必须先实名才能充值
|
||
if resolved.Asset.RealnamePolicy == constants.RealnamePolicyBeforeOrder && resolved.Asset.RealNameStatus != 1 {
|
||
return errors.New(errors.CodeNeedRealname)
|
||
}
|
||
|
||
check, err := h.rechargeService.GetRechargeCheck(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
resp := &dto.ClientRechargeCheckResponse{
|
||
NeedForceRecharge: check.NeedForceRecharge,
|
||
ForceRechargeAmount: check.ForceRechargeAmount,
|
||
TriggerType: check.TriggerType,
|
||
MinAmount: check.MinAmount,
|
||
MaxAmount: check.MaxAmount,
|
||
Message: check.Message,
|
||
}
|
||
|
||
return response.Success(c, resp)
|
||
}
|
||
|
||
// CreateRecharge C4 创建充值订单
|
||
// POST /api/c/v1/wallet/recharge
|
||
func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
|
||
var req dto.ClientCreateRechargeRequest
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return errors.New(errors.CodeInvalidParam)
|
||
}
|
||
|
||
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// before_order 策略:必须先实名才能充值
|
||
if resolved.Asset.RealnamePolicy == constants.RealnamePolicyBeforeOrder && resolved.Asset.RealNameStatus != 1 {
|
||
return errors.New(errors.CodeNeedRealname)
|
||
}
|
||
|
||
wallet, err := h.getOrCreateWallet(resolved)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
config, err := h.wechatConfigService.GetActiveConfig(resolved.SkipPermissionCtx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if config == nil {
|
||
return errors.New(errors.CodeWechatConfigUnavailable)
|
||
}
|
||
|
||
switch req.PaymentMethod {
|
||
case constants.RechargeMethodAlipay:
|
||
return h.createAlipayRecharge(c, resolved, config, wallet, req)
|
||
default:
|
||
// 微信充值(默认):app_type 必填
|
||
return h.createWechatRecharge(c, resolved, config, wallet, req)
|
||
}
|
||
}
|
||
|
||
// createWechatRecharge 微信充值分支
|
||
func (h *ClientWalletHandler) createWechatRecharge(
|
||
c *fiber.Ctx,
|
||
resolved *resolvedWalletAssetContext,
|
||
config *model.WechatConfig,
|
||
wallet *model.AssetWallet,
|
||
req dto.ClientCreateRechargeRequest,
|
||
) error {
|
||
appID, err := pickAppIDByType(config, req.AppType)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
openID, err := h.findOpenIDByCustomerAndAppID(resolved.SkipPermissionCtx, resolved.CustomerID, appID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
rechargeNo := generateClientRechargeNo()
|
||
paymentNo := generateClientPaymentNo()
|
||
|
||
// 先初始化生效支付通道并创建预支付订单,确认支付通道可用
|
||
// 避免先写入充值记录后支付初始化失败,导致产生孤儿记录
|
||
payConfig, err := h.createClientRechargePayConfig(
|
||
resolved.SkipPermissionCtx,
|
||
config,
|
||
appID,
|
||
req.AppType,
|
||
paymentNo,
|
||
"资产钱包充值",
|
||
openID,
|
||
int(req.Amount),
|
||
)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 支付通道确认可用后,再创建充值订单和支付记录
|
||
rechargeOrder := &model.RechargeOrder{
|
||
RechargeOrderNo: rechargeNo,
|
||
UserID: resolved.CustomerID,
|
||
AssetWalletID: wallet.ID,
|
||
ResourceType: resolved.ResourceType,
|
||
ResourceID: resolved.Asset.AssetID,
|
||
Amount: req.Amount,
|
||
Status: model.RechargeOrderStatusPending,
|
||
PaymentConfigID: &config.ID,
|
||
ShopIDTag: wallet.ShopIDTag,
|
||
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||
OperatorType: constants.OperatorTypePersonalCustomer,
|
||
Generation: resolved.Generation,
|
||
}
|
||
if err := h.rechargeOrderStore.Create(resolved.SkipPermissionCtx, rechargeOrder); err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
|
||
}
|
||
|
||
payment := &model.Payment{
|
||
PaymentNo: paymentNo,
|
||
OrderID: rechargeOrder.ID,
|
||
OrderType: model.PaymentOrderTypeRecharge,
|
||
PaymentMethod: model.PaymentByWechat,
|
||
Amount: req.Amount,
|
||
Status: model.PaymentRecordStatusPending,
|
||
PaymentConfigID: &config.ID,
|
||
}
|
||
if err := h.paymentStore.Create(resolved.SkipPermissionCtx, payment); err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
|
||
}
|
||
|
||
return response.Success(c, &dto.ClientRechargeResponse{
|
||
Recharge: dto.ClientRechargeResult{
|
||
RechargeID: rechargeOrder.ID,
|
||
RechargeNo: rechargeOrder.RechargeOrderNo,
|
||
Amount: rechargeOrder.Amount,
|
||
Status: rechargeOrder.Status,
|
||
},
|
||
PayConfig: &payConfig,
|
||
})
|
||
}
|
||
|
||
// createAlipayRecharge 支付宝充值分支。
|
||
// WAP URL 为本地签名生成,事务内先写 DB,再生成链接;链接失败则标记 payment failed。
|
||
func (h *ClientWalletHandler) createAlipayRecharge(
|
||
c *fiber.Ctx,
|
||
resolved *resolvedWalletAssetContext,
|
||
config *model.WechatConfig,
|
||
wallet *model.AssetWallet,
|
||
req dto.ClientCreateRechargeRequest,
|
||
) error {
|
||
rechargeNo := generateClientRechargeNo()
|
||
paymentNo := generateClientPaymentNo()
|
||
expireMinutes := config.AliPayExpireMinutes
|
||
if expireMinutes <= 0 {
|
||
expireMinutes = 30
|
||
}
|
||
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
|
||
|
||
rechargeOrder := &model.RechargeOrder{
|
||
RechargeOrderNo: rechargeNo,
|
||
UserID: resolved.CustomerID,
|
||
AssetWalletID: wallet.ID,
|
||
ResourceType: resolved.ResourceType,
|
||
ResourceID: resolved.Asset.AssetID,
|
||
Amount: req.Amount,
|
||
Status: model.RechargeOrderStatusPending,
|
||
PaymentConfigID: &config.ID,
|
||
ShopIDTag: wallet.ShopIDTag,
|
||
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||
OperatorType: constants.OperatorTypePersonalCustomer,
|
||
Generation: resolved.Generation,
|
||
}
|
||
payment := &model.Payment{
|
||
PaymentNo: paymentNo,
|
||
OrderType: model.PaymentOrderTypeRecharge,
|
||
PaymentMethod: model.PaymentByAlipay,
|
||
Amount: req.Amount,
|
||
Status: model.PaymentRecordStatusPending,
|
||
PaymentConfigID: &config.ID,
|
||
ExpireAt: &expireAt,
|
||
}
|
||
|
||
// WAP URL 是本地签名,不需要先调第三方,在事务内创建充值单和支付单
|
||
if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error {
|
||
if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
|
||
}
|
||
payment.OrderID = rechargeOrder.ID
|
||
return h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment)
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
|
||
wapURL, err := alipay.BuildWapPayURL(resolved.SkipPermissionCtx, config, payment, "资产钱包充值")
|
||
if err != nil {
|
||
if updateErr := h.paymentStore.UpdateStatus(resolved.SkipPermissionCtx, payment.ID, model.PaymentRecordStatusFailed); updateErr != nil {
|
||
h.logger.Warn("标记支付宝支付单 failed 失败",
|
||
zap.String("payment_no", paymentNo),
|
||
zap.Error(updateErr),
|
||
)
|
||
}
|
||
return err
|
||
}
|
||
|
||
h.logger.Info("支付宝充值支付单已创建",
|
||
zap.String("payment_no", paymentNo),
|
||
zap.String("recharge_no", rechargeNo),
|
||
zap.Int64("amount", req.Amount),
|
||
zap.Time("expire_at", expireAt),
|
||
zap.Uint("config_id", config.ID),
|
||
)
|
||
|
||
expireStr := expireAt.Format(time.RFC3339)
|
||
return response.Success(c, &dto.ClientRechargeResponse{
|
||
Recharge: dto.ClientRechargeResult{
|
||
RechargeID: rechargeOrder.ID,
|
||
RechargeNo: rechargeOrder.RechargeOrderNo,
|
||
Amount: rechargeOrder.Amount,
|
||
Status: rechargeOrder.Status,
|
||
},
|
||
PaymentLink: &dto.ClientPaymentLink{
|
||
PaymentNo: paymentNo,
|
||
QRLink: wapURL,
|
||
CopyLink: wapURL,
|
||
PayExpireAt: expireStr,
|
||
},
|
||
})
|
||
}
|
||
|
||
// GetRechargeList C5 充值记录列表
|
||
// GET /api/c/v1/wallet/recharges
|
||
func (h *ClientWalletHandler) GetRechargeList(c *fiber.Ctx) error {
|
||
var req dto.ClientRechargeListRequest
|
||
if err := c.QueryParser(&req); err != nil {
|
||
return errors.New(errors.CodeInvalidParam)
|
||
}
|
||
|
||
if req.Page < 1 {
|
||
req.Page = 1
|
||
}
|
||
if req.PageSize < 1 {
|
||
req.PageSize = constants.DefaultPageSize
|
||
}
|
||
if req.PageSize > constants.MaxPageSize {
|
||
req.PageSize = constants.MaxPageSize
|
||
}
|
||
|
||
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
query := h.db.WithContext(resolved.SkipPermissionCtx).
|
||
Model(&model.RechargeOrder{}).
|
||
Where("resource_type = ? AND resource_id = ? AND generation = ?", resolved.ResourceType, resolved.Asset.AssetID, resolved.Generation)
|
||
if req.Status != nil {
|
||
query = query.Where("status = ?", *req.Status)
|
||
}
|
||
|
||
var total int64
|
||
if err := query.Count(&total).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单总数失败")
|
||
}
|
||
|
||
var orders []*model.RechargeOrder
|
||
offset := (req.Page - 1) * req.PageSize
|
||
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&orders).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败")
|
||
}
|
||
|
||
items := make([]dto.ClientRechargeListItem, 0, len(orders))
|
||
for _, order := range orders {
|
||
if order == nil {
|
||
continue
|
||
}
|
||
items = append(items, dto.ClientRechargeListItem{
|
||
RechargeID: order.ID,
|
||
RechargeNo: order.RechargeOrderNo,
|
||
Amount: order.Amount,
|
||
Status: order.Status,
|
||
PaymentMethod: "", // 支付方式在 Payment 表中,列表查询不返回
|
||
CreatedAt: order.CreatedAt.Format(time.RFC3339),
|
||
AutoPurchaseStatus: order.AutoPurchaseStatus,
|
||
})
|
||
}
|
||
|
||
return response.SuccessWithPagination(c, items, total, req.Page, req.PageSize)
|
||
}
|
||
|
||
// resolveAssetFromIdentifier 统一执行资产解析与归属校验
|
||
func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedWalletAssetContext, error) {
|
||
customerID, ok := middleware.GetCustomerID(c)
|
||
if !ok || customerID == 0 {
|
||
return nil, errors.New(errors.CodeUnauthorized)
|
||
}
|
||
|
||
identifier = strings.TrimSpace(identifier)
|
||
if identifier == "" {
|
||
identifier = strings.TrimSpace(c.Query("identifier"))
|
||
}
|
||
if identifier == "" {
|
||
return nil, errors.New(errors.CodeInvalidParam)
|
||
}
|
||
|
||
skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{})
|
||
assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
owned, ownErr := h.customerBinding.OwnsAsset(skipPermissionCtx, customerID, assetInfo.AssetType, assetInfo.AssetID)
|
||
if ownErr != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
|
||
}
|
||
if !owned {
|
||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
|
||
}
|
||
|
||
resourceType, mapErr := mapAssetTypeToWalletResource(assetInfo.AssetType)
|
||
if mapErr != nil {
|
||
return nil, mapErr
|
||
}
|
||
|
||
generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID)
|
||
if genErr != nil {
|
||
return nil, genErr
|
||
}
|
||
|
||
return &resolvedWalletAssetContext{
|
||
CustomerID: customerID,
|
||
Identifier: identifier,
|
||
Asset: assetInfo,
|
||
Generation: generation,
|
||
ResourceType: resourceType,
|
||
SkipPermissionCtx: skipPermissionCtx,
|
||
}, nil
|
||
}
|
||
|
||
|
||
func (h *ClientWalletHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
|
||
switch assetType {
|
||
case "card":
|
||
card, err := h.iotCardStore.GetByID(ctx, assetID)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return 0, errors.New(errors.CodeAssetNotFound)
|
||
}
|
||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
|
||
}
|
||
return card.Generation, nil
|
||
case "device":
|
||
device, err := h.deviceStore.GetByID(ctx, assetID)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return 0, errors.New(errors.CodeAssetNotFound)
|
||
}
|
||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
|
||
}
|
||
return device.Generation, nil
|
||
default:
|
||
return 0, errors.New(errors.CodeInvalidParam)
|
||
}
|
||
}
|
||
|
||
func (h *ClientWalletHandler) getOrCreateWallet(resolved *resolvedWalletAssetContext) (*model.AssetWallet, error) {
|
||
wallet, err := h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
|
||
if err == nil {
|
||
return wallet, nil
|
||
}
|
||
if err != gorm.ErrRecordNotFound {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
|
||
}
|
||
|
||
shopIDTag := uint(0)
|
||
if resolved.Asset.ShopID != nil {
|
||
shopIDTag = *resolved.Asset.ShopID
|
||
}
|
||
|
||
newWallet := &model.AssetWallet{
|
||
ResourceType: resolved.ResourceType,
|
||
ResourceID: resolved.Asset.AssetID,
|
||
Balance: 0,
|
||
FrozenBalance: 0,
|
||
Currency: "CNY",
|
||
Status: constants.AssetWalletStatusNormal,
|
||
Version: 0,
|
||
ShopIDTag: shopIDTag,
|
||
}
|
||
if createErr := h.walletStore.Create(resolved.SkipPermissionCtx, newWallet); createErr != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, createErr, "创建钱包失败")
|
||
}
|
||
|
||
wallet, err = h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
|
||
}
|
||
return wallet, nil
|
||
}
|
||
|
||
func (h *ClientWalletHandler) findOpenIDByCustomerAndAppID(ctx context.Context, customerID uint, appID string) (string, error) {
|
||
list, err := h.openIDStore.ListByCustomerID(ctx, customerID)
|
||
if err != nil {
|
||
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权信息失败")
|
||
}
|
||
for _, item := range list {
|
||
if item == nil {
|
||
continue
|
||
}
|
||
if item.AppID == appID && strings.TrimSpace(item.OpenID) != "" {
|
||
return item.OpenID, nil
|
||
}
|
||
}
|
||
return "", errors.New(errors.CodeOpenIDNotFound)
|
||
}
|
||
|
||
func mapAssetTypeToWalletResource(assetType string) (string, error) {
|
||
switch assetType {
|
||
case "card":
|
||
return constants.AssetWalletResourceTypeIotCard, nil
|
||
case "device":
|
||
return constants.AssetWalletResourceTypeDevice, nil
|
||
default:
|
||
return "", errors.New(errors.CodeInvalidParam)
|
||
}
|
||
}
|
||
|
||
func parseOptionalTime(value string) (*time.Time, error) {
|
||
v := strings.TrimSpace(value)
|
||
if v == "" {
|
||
return nil, nil
|
||
}
|
||
|
||
layouts := []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"}
|
||
for _, layout := range layouts {
|
||
t, err := time.Parse(layout, v)
|
||
if err == nil {
|
||
return &t, nil
|
||
}
|
||
}
|
||
|
||
return nil, fmt.Errorf("invalid time format")
|
||
}
|
||
|
||
func pickAppIDByType(config *model.WechatConfig, appType string) (string, error) {
|
||
switch appType {
|
||
case "official_account":
|
||
if strings.TrimSpace(config.OaAppID) == "" {
|
||
return "", errors.New(errors.CodeWechatConfigUnavailable)
|
||
}
|
||
return config.OaAppID, nil
|
||
case "miniapp":
|
||
if strings.TrimSpace(config.MiniappAppID) == "" {
|
||
return "", errors.New(errors.CodeWechatConfigUnavailable)
|
||
}
|
||
return config.MiniappAppID, nil
|
||
default:
|
||
return "", errors.New(errors.CodeInvalidParam)
|
||
}
|
||
}
|
||
|
||
// createClientRechargePayConfig 根据当前生效支付配置创建充值预支付订单。
|
||
// provider_type 必须显式匹配,避免富友等渠道误落到微信直连。
|
||
func (h *ClientWalletHandler) createClientRechargePayConfig(
|
||
ctx context.Context,
|
||
config *model.WechatConfig,
|
||
appID, appType, orderNo, description, openID string,
|
||
amount int,
|
||
) (dto.ClientRechargePayConfig, error) {
|
||
switch config.ProviderType {
|
||
case model.ProviderTypeWechatV2:
|
||
// 微信 v2:仅需 APIv2Key,无需证书序列号
|
||
svc, err := wechat.NewPaymentV2ServiceFromConfig(config, appID, h.logger)
|
||
if err != nil {
|
||
return dto.ClientRechargePayConfig{}, errors.Wrap(errors.CodeWechatPayFailed, err, "初始化微信 v2 支付实例失败")
|
||
}
|
||
result, err := svc.CreateJSAPIOrder(ctx, orderNo, description, openID, amount)
|
||
if err != nil {
|
||
return dto.ClientRechargePayConfig{}, err
|
||
}
|
||
return buildClientRechargePayConfig(appID, result), nil
|
||
case model.ProviderTypeWechat:
|
||
// 微信 v3(PowerWeChat):需要证书序列号和 API v3 Key
|
||
cache := wechat.NewRedisCache(h.redis)
|
||
paymentApp, err := wechat.NewPaymentAppFromConfig(config, appID, cache, h.logger)
|
||
if err != nil {
|
||
return dto.ClientRechargePayConfig{}, errors.Wrap(errors.CodeWechatPayFailed, err, "初始化微信支付实例失败")
|
||
}
|
||
result, err := wechat.NewPaymentService(paymentApp, h.logger).CreateJSAPIOrder(ctx, orderNo, description, openID, amount)
|
||
if err != nil {
|
||
return dto.ClientRechargePayConfig{}, err
|
||
}
|
||
return buildClientRechargePayConfig(appID, result), nil
|
||
case model.ProviderTypeFuiou:
|
||
return h.createFuiouRechargePayConfig(ctx, config, appID, appType, orderNo, description, openID, amount)
|
||
default:
|
||
return dto.ClientRechargePayConfig{}, errors.New(errors.CodeWechatConfigUnavailable, "不支持的支付渠道类型")
|
||
}
|
||
}
|
||
|
||
// createFuiouRechargePayConfig 使用富友支付配置创建充值预支付订单。
|
||
func (h *ClientWalletHandler) createFuiouRechargePayConfig(
|
||
ctx context.Context,
|
||
config *model.WechatConfig,
|
||
appID, appType, orderNo, description, openID string,
|
||
amount int,
|
||
) (dto.ClientRechargePayConfig, error) {
|
||
if strings.TrimSpace(config.FyInsCd) == "" ||
|
||
strings.TrimSpace(config.FyMchntCd) == "" ||
|
||
strings.TrimSpace(config.FyTermID) == "" ||
|
||
strings.TrimSpace(config.FyPrivateKey) == "" ||
|
||
strings.TrimSpace(config.FyPublicKey) == "" ||
|
||
strings.TrimSpace(config.FyAPIURL) == "" ||
|
||
strings.TrimSpace(config.FyNotifyURL) == "" ||
|
||
strings.TrimSpace(appID) == "" {
|
||
return dto.ClientRechargePayConfig{}, errors.New(errors.CodeFuiouPayFailed, "富友支付配置不完整")
|
||
}
|
||
|
||
client, err := fuiou.NewClient(
|
||
config.FyInsCd,
|
||
config.FyMchntCd,
|
||
config.FyTermID,
|
||
config.FyAPIURL,
|
||
config.FyNotifyURL,
|
||
config.FyPrivateKey,
|
||
config.FyPublicKey,
|
||
h.logger,
|
||
)
|
||
if err != nil {
|
||
return dto.ClientRechargePayConfig{}, errors.Wrap(errors.CodeFuiouPayFailed, err, "创建富友支付客户端失败")
|
||
}
|
||
|
||
tradeType := "JSAPI"
|
||
if appType == "miniapp" {
|
||
tradeType = "LETPAY"
|
||
}
|
||
|
||
termIP := fuiou.GetServerIP()
|
||
if ip, ok := ctx.Value(constants.ContextKeyIP).(string); ok && strings.TrimSpace(ip) != "" {
|
||
termIP = ip
|
||
}
|
||
|
||
resp, err := client.WxPreCreate(orderNo, strconv.Itoa(amount), description, termIP, tradeType, appID, openID)
|
||
if err != nil {
|
||
h.logger.Error("富友充值预下单失败",
|
||
zap.String("payment_no", orderNo),
|
||
zap.String("trade_type", tradeType),
|
||
zap.Error(err),
|
||
)
|
||
return dto.ClientRechargePayConfig{}, errors.Wrap(errors.CodeFuiouPayFailed, err, "富友预下单失败")
|
||
}
|
||
|
||
return dto.ClientRechargePayConfig{
|
||
AppID: resp.SdkAppid,
|
||
Timestamp: resp.SdkTimestamp,
|
||
NonceStr: resp.SdkNoncestr,
|
||
PackageVal: resp.SdkPackage,
|
||
SignType: resp.SdkSigntype,
|
||
PaySign: resp.SdkPaysign,
|
||
}, nil
|
||
}
|
||
|
||
func generateClientRechargeNo() string {
|
||
timestamp := time.Now().Format("20060102150405")
|
||
randomNum := rand.Intn(1000000)
|
||
return fmt.Sprintf("%s%s%06d", constants.AssetRechargeOrderPrefix, timestamp, randomNum)
|
||
}
|
||
|
||
func generateClientPaymentNo() string {
|
||
return fmt.Sprintf("PAY%d%06d", time.Now().UnixNano()/1e6, rand.Intn(1000000))
|
||
}
|
||
|
||
func buildClientRechargePayConfig(appID string, result *wechat.JSAPIPayResult) dto.ClientRechargePayConfig {
|
||
resp := dto.ClientRechargePayConfig{AppID: appID}
|
||
if result == nil || result.PayConfig == nil {
|
||
return resp
|
||
}
|
||
|
||
if cfg, ok := result.PayConfig.(map[string]any); ok {
|
||
resp.Timestamp = getStringFromAnyMap(cfg, "timeStamp", "timestamp")
|
||
resp.NonceStr = getStringFromAnyMap(cfg, "nonceStr", "nonce_str")
|
||
resp.PackageVal = getStringFromAnyMap(cfg, "package")
|
||
resp.SignType = getStringFromAnyMap(cfg, "signType", "sign_type")
|
||
resp.PaySign = getStringFromAnyMap(cfg, "paySign", "pay_sign")
|
||
if appIDVal := getStringFromAnyMap(cfg, "appId", "app_id"); appIDVal != "" {
|
||
resp.AppID = appIDVal
|
||
}
|
||
return resp
|
||
}
|
||
|
||
if cfg, ok := result.PayConfig.(map[string]string); ok {
|
||
resp.Timestamp = cfg["timeStamp"]
|
||
if resp.Timestamp == "" {
|
||
resp.Timestamp = cfg["timestamp"]
|
||
}
|
||
resp.NonceStr = cfg["nonceStr"]
|
||
if resp.NonceStr == "" {
|
||
resp.NonceStr = cfg["nonce_str"]
|
||
}
|
||
resp.PackageVal = cfg["package"]
|
||
resp.SignType = cfg["signType"]
|
||
if resp.SignType == "" {
|
||
resp.SignType = cfg["sign_type"]
|
||
}
|
||
resp.PaySign = cfg["paySign"]
|
||
if resp.PaySign == "" {
|
||
resp.PaySign = cfg["pay_sign"]
|
||
}
|
||
if cfg["appId"] != "" {
|
||
resp.AppID = cfg["appId"]
|
||
} else if cfg["app_id"] != "" {
|
||
resp.AppID = cfg["app_id"]
|
||
}
|
||
}
|
||
|
||
return resp
|
||
}
|
||
|
||
func getStringFromAnyMap(m map[string]any, keys ...string) string {
|
||
for _, key := range keys {
|
||
val, ok := m[key]
|
||
if !ok || val == nil {
|
||
continue
|
||
}
|
||
switch v := val.(type) {
|
||
case string:
|
||
if v != "" {
|
||
return v
|
||
}
|
||
case fmt.Stringer:
|
||
text := v.String()
|
||
if text != "" {
|
||
return text
|
||
}
|
||
default:
|
||
text := fmt.Sprintf("%v", v)
|
||
if text != "" && text != "<nil>" {
|
||
return text
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|