Files
junhong_cmp_fiber/internal/handler/app/client_wallet.go
huang 9bd55a1695 feat: 实现客户端核心业务接口(client-core-business-api)
新增客户端资产、钱包、订单、实名、设备管理等核心业务 Handler 与 DTO:
- 客户端资产信息查询、套餐列表、套餐历史、资产刷新
- 客户端钱包详情、流水、充值校验、充值订单、充值记录
- 客户端订单创建、列表、详情
- 客户端实名认证链接获取
- 客户端设备卡列表、重启、恢复出厂、WiFi配置、切卡
- 客户端订单服务(含微信/支付宝支付流程)
- 强充自动代购异步任务处理
- 数据库迁移 000084:充值记录增加自动代购状态字段
2026-03-19 13:28:04 +08:00

661 lines
19 KiB
Go

package app
import (
"context"
"fmt"
"math/rand"
"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"
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/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"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
personalDeviceStore *postgres.PersonalCustomerDeviceStore
walletStore *postgres.AssetWalletStore
transactionStore *postgres.AssetWalletTransactionStore
rechargeStore *postgres.AssetRechargeStore
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,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
walletStore *postgres.AssetWalletStore,
transactionStore *postgres.AssetWalletTransactionStore,
rechargeStore *postgres.AssetRechargeStore,
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,
personalDeviceStore: personalDeviceStore,
walletStore: walletStore,
transactionStore: transactionStore,
rechargeStore: rechargeStore,
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
}
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)
}
if req.PaymentMethod != constants.RechargeMethodWechat {
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
}
config, err := h.wechatConfigService.GetActiveConfig(resolved.SkipPermissionCtx)
if err != nil {
return err
}
if config == nil {
return errors.New(errors.CodeWechatConfigUnavailable)
}
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()
recharge := &model.AssetRechargeRecord{
UserID: resolved.CustomerID,
AssetWalletID: wallet.ID,
ResourceType: resolved.ResourceType,
ResourceID: resolved.Asset.AssetID,
RechargeNo: rechargeNo,
Amount: req.Amount,
PaymentMethod: constants.RechargeMethodWechat,
PaymentConfigID: &config.ID,
Status: constants.RechargeStatusPending,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
OperatorType: constants.OperatorTypePersonalCustomer,
Generation: resolved.Generation,
}
if err := h.rechargeStore.Create(resolved.SkipPermissionCtx, recharge); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值记录失败")
}
cache := wechat.NewRedisCache(h.redis)
paymentApp, err := wechat.NewPaymentAppFromConfig(config, appID, cache, h.logger)
if err != nil {
return errors.Wrap(errors.CodeWechatPayFailed, err, "初始化微信支付实例失败")
}
paymentService := wechat.NewPaymentService(paymentApp, h.logger)
payResult, err := paymentService.CreateJSAPIOrder(
resolved.SkipPermissionCtx,
recharge.RechargeNo,
"资产钱包充值",
openID,
int(req.Amount),
)
if err != nil {
return err
}
payConfig := buildClientRechargePayConfig(appID, payResult)
resp := &dto.ClientRechargeResponse{
Recharge: dto.ClientRechargeResult{
RechargeID: recharge.ID,
RechargeNo: recharge.RechargeNo,
Amount: recharge.Amount,
Status: recharge.Status,
},
PayConfig: payConfig,
}
return response.Success(c, resp)
}
// 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.AssetRechargeRecord{}).
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 records []*model.AssetRechargeRecord
offset := (req.Page - 1) * req.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&records).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录失败")
}
items := make([]dto.ClientRechargeListItem, 0, len(records))
for _, record := range records {
if record == nil {
continue
}
items = append(items, dto.ClientRechargeListItem{
RechargeID: record.ID,
RechargeNo: record.RechargeNo,
Amount: record.Amount,
Status: record.Status,
PaymentMethod: record.PaymentMethod,
CreatedAt: record.CreatedAt.Format(time.RFC3339),
AutoPurchaseStatus: record.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.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo)
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) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) {
records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID)
if err != nil {
return false, err
}
for _, record := range records {
if record == nil {
continue
}
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
return true, nil
}
}
return false, 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)
}
}
func generateClientRechargeNo() string {
timestamp := time.Now().Format("20060102150405")
randomNum := rand.Intn(1000000)
return fmt.Sprintf("%s%s%06d", constants.AssetRechargeOrderPrefix, timestamp, randomNum)
}
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 ""
}